← Volver al listado de tecnologías

Capítulo 6: Setup del Proyecto OrderFlow

Por: SiempreListo
sagatypescriptmicroserviciossetup

Capítulo 6: Setup del Proyecto OrderFlow

“Una arquitectura sólida comienza con una buena estructura”

Introducción

A partir de este capítulo, comenzamos la implementación práctica de un sistema de procesamiento de pedidos usando el patrón Saga. Construiremos OrderFlow, un conjunto de microservicios que colaboran para procesar pedidos de forma distribuida.

Este capítulo establece la estructura del proyecto usando tecnologías modernas:

Arquitectura del Proyecto

orderflow/
├── packages/
│   ├── shared/           # Tipos y utilidades compartidas
│   ├── order-service/    # Servicio de órdenes
│   ├── inventory-service/# Servicio de inventario
│   ├── payment-service/  # Servicio de pagos
│   └── saga-orchestrator/# Orquestador de sagas
├── docker-compose.yml
├── package.json
└── tsconfig.json

Configuración Base

Usamos workspaces para gestionar múltiples paquetes en un solo repositorio. Esto permite compartir dependencias y código entre servicios.

Turbo es una herramienta de build que optimiza la ejecución de tareas en monorepos, ejecutando en paralelo lo que puede y cacheando resultados.

package.json (raíz)

{
  "name": "orderflow",
  "private": true,
  "workspaces": ["packages/*"],
  "scripts": {
    "dev": "turbo run dev",
    "build": "turbo run build",
    "test": "turbo run test",
    "db:migrate": "turbo run db:migrate"
  },
  "devDependencies": {
    "turbo": "^2.0.0",
    "typescript": "^5.4.0"
  }
}

tsconfig.json (raíz)

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "declaration": true,
    "outDir": "dist"
  }
}

Paquete Shared

El paquete shared contiene código común que todos los servicios necesitan: tipos TypeScript, interfaces de eventos, y utilidades compartidas. Esto evita duplicar definiciones y asegura consistencia.

Zod es una librería de validación de esquemas que permite definir la estructura de los datos y validarlos en tiempo de ejecución, generando además los tipos TypeScript automáticamente.

packages/shared/package.json

{
  "name": "@orderflow/shared",
  "version": "1.0.0",
  "type": "module",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch"
  },
  "dependencies": {
    "zod": "^3.23.0"
  }
}

packages/shared/src/types/order.ts

import { z } from 'zod';

export const OrderItemSchema = z.object({
  productId: z.string().uuid(),
  quantity: z.number().int().positive(),
  unitPrice: z.number().positive()
});

export const CreateOrderSchema = z.object({
  customerId: z.string().uuid(),
  items: z.array(OrderItemSchema).min(1),
  shippingAddress: z.object({
    street: z.string(),
    city: z.string(),
    country: z.string(),
    zipCode: z.string()
  })
});

export const OrderStatusSchema = z.enum([
  'pending',
  'stock_reserved',
  'payment_processed',
  'completed',
  'cancelled',
  'compensation_pending'
]);

export type OrderItem = z.infer<typeof OrderItemSchema>;
export type CreateOrderInput = z.infer<typeof CreateOrderSchema>;
export type OrderStatus = z.infer<typeof OrderStatusSchema>;

export interface Order {
  id: string;
  customerId: string;
  items: OrderItem[];
  total: number;
  status: OrderStatus;
  createdAt: Date;
  updatedAt: Date;
}

packages/shared/src/types/saga.ts

export type SagaStatus =
  | 'started'
  | 'step_completed'
  | 'completed'
  | 'failed'
  | 'compensating'
  | 'compensated';

export interface SagaStep {
  name: string;
  status: 'pending' | 'completed' | 'failed' | 'compensated';
  executedAt?: Date;
  compensatedAt?: Date;
  error?: string;
}

export interface SagaState {
  id: string;
  orderId: string;
  status: SagaStatus;
  currentStep: number;
  steps: SagaStep[];
  context: Record<string, unknown>;
  startedAt: Date;
  completedAt?: Date;
}

export interface SagaEvent {
  sagaId: string;
  type: string;
  payload: unknown;
  timestamp: Date;
}

packages/shared/src/types/events.ts

export interface BaseEvent {
  id: string;
  timestamp: Date;
  correlationId: string;
}

export interface OrderCreatedEvent extends BaseEvent {
  type: 'OrderCreated';
  payload: {
    orderId: string;
    customerId: string;
    items: Array<{ productId: string; quantity: number }>;
    total: number;
  };
}

export interface StockReservedEvent extends BaseEvent {
  type: 'StockReserved';
  payload: {
    orderId: string;
    reservationId: string;
    items: Array<{ productId: string; quantity: number }>;
  };
}

export interface StockReservationFailedEvent extends BaseEvent {
  type: 'StockReservationFailed';
  payload: {
    orderId: string;
    reason: string;
  };
}

export interface PaymentProcessedEvent extends BaseEvent {
  type: 'PaymentProcessed';
  payload: {
    orderId: string;
    paymentId: string;
    amount: number;
  };
}

export interface PaymentFailedEvent extends BaseEvent {
  type: 'PaymentFailed';
  payload: {
    orderId: string;
    reason: string;
  };
}

export type DomainEvent =
  | OrderCreatedEvent
  | StockReservedEvent
  | StockReservationFailedEvent
  | PaymentProcessedEvent
  | PaymentFailedEvent;

packages/shared/src/messaging/event-bus.ts

import { DomainEvent } from '../types/events.js';

type EventHandler<T extends DomainEvent> = (event: T) => Promise<void>;

export class InMemoryEventBus {
  private handlers = new Map<string, EventHandler<DomainEvent>[]>();

  subscribe<T extends DomainEvent>(eventType: T['type'], handler: EventHandler<T>): void {
    const existing = this.handlers.get(eventType) || [];
    this.handlers.set(eventType, [...existing, handler as EventHandler<DomainEvent>]);
  }

  async publish(event: DomainEvent): Promise<void> {
    const handlers = this.handlers.get(event.type) || [];
    await Promise.all(handlers.map(h => h(event)));
  }
}

// Singleton para desarrollo local
export const eventBus = new InMemoryEventBus();

packages/shared/src/index.ts

export * from './types/order.js';
export * from './types/saga.js';
export * from './types/events.js';
export * from './messaging/event-bus.js';

Docker Compose

Docker Compose es una herramienta que permite definir y ejecutar aplicaciones multi-contenedor. Con un solo archivo YAML describimos todos nuestros servicios y sus dependencias.

Cada servicio se ejecuta en su propio contenedor aislado, comunicándose a través de una red interna de Docker.

version: '3.8'

services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: orderflow
      POSTGRES_PASSWORD: orderflow
      POSTGRES_DB: orderflow
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./init-db.sql:/docker-entrypoint-initdb.d/init.sql

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data

  order-service:
    build:
      context: .
      dockerfile: packages/order-service/Dockerfile
    ports:
      - "3001:3000"
    environment:
      DATABASE_URL: postgres://orderflow:orderflow@postgres:5432/orderflow
      REDIS_URL: redis://redis:6379
    depends_on:
      - postgres
      - redis

  inventory-service:
    build:
      context: .
      dockerfile: packages/inventory-service/Dockerfile
    ports:
      - "3002:3000"
    environment:
      DATABASE_URL: postgres://orderflow:orderflow@postgres:5432/orderflow
      REDIS_URL: redis://redis:6379
    depends_on:
      - postgres
      - redis

  payment-service:
    build:
      context: .
      dockerfile: packages/payment-service/Dockerfile
    ports:
      - "3003:3000"
    environment:
      DATABASE_URL: postgres://orderflow:orderflow@postgres:5432/orderflow
      REDIS_URL: redis://redis:6379
    depends_on:
      - postgres
      - redis

  saga-orchestrator:
    build:
      context: .
      dockerfile: packages/saga-orchestrator/Dockerfile
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://orderflow:orderflow@postgres:5432/orderflow
      REDIS_URL: redis://redis:6379
      ORDER_SERVICE_URL: http://order-service:3000
      INVENTORY_SERVICE_URL: http://inventory-service:3000
      PAYMENT_SERVICE_URL: http://payment-service:3000
    depends_on:
      - postgres
      - redis
      - order-service
      - inventory-service
      - payment-service

volumes:
  postgres_data:
  redis_data:

Script de Inicialización de BD

init-db.sql

-- Esquema de órdenes
CREATE TABLE IF NOT EXISTS orders (
    id UUID PRIMARY KEY,
    customer_id UUID NOT NULL,
    total DECIMAL(10,2) NOT NULL,
    status VARCHAR(50) NOT NULL DEFAULT 'pending',
    shipping_address JSONB NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE IF NOT EXISTS order_items (
    id UUID PRIMARY KEY,
    order_id UUID REFERENCES orders(id),
    product_id UUID NOT NULL,
    quantity INTEGER NOT NULL,
    unit_price DECIMAL(10,2) NOT NULL
);

-- Esquema de inventario
CREATE TABLE IF NOT EXISTS inventory (
    product_id UUID PRIMARY KEY,
    available INTEGER NOT NULL DEFAULT 0,
    reserved INTEGER NOT NULL DEFAULT 0
);

CREATE TABLE IF NOT EXISTS stock_reservations (
    id UUID PRIMARY KEY,
    order_id UUID NOT NULL,
    product_id UUID NOT NULL,
    quantity INTEGER NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'active',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Esquema de pagos
CREATE TABLE IF NOT EXISTS payments (
    id UUID PRIMARY KEY,
    order_id UUID NOT NULL,
    amount DECIMAL(10,2) NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'pending',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Esquema de sagas
CREATE TABLE IF NOT EXISTS sagas (
    id UUID PRIMARY KEY,
    order_id UUID NOT NULL,
    status VARCHAR(50) NOT NULL,
    current_step INTEGER NOT NULL DEFAULT 0,
    context JSONB NOT NULL DEFAULT '{}',
    started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    completed_at TIMESTAMP
);

CREATE TABLE IF NOT EXISTS saga_steps (
    id UUID PRIMARY KEY,
    saga_id UUID REFERENCES sagas(id),
    name VARCHAR(100) NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'pending',
    executed_at TIMESTAMP,
    compensated_at TIMESTAMP,
    error TEXT
);

-- Índices
CREATE INDEX idx_orders_customer ON orders(customer_id);
CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_reservations_order ON stock_reservations(order_id);
CREATE INDEX idx_payments_order ON payments(order_id);
CREATE INDEX idx_sagas_order ON sagas(order_id);
CREATE INDEX idx_sagas_status ON sagas(status);

-- Datos de prueba
INSERT INTO inventory (product_id, available, reserved) VALUES
    ('550e8400-e29b-41d4-a716-446655440001', 100, 0),
    ('550e8400-e29b-41d4-a716-446655440002', 50, 0),
    ('550e8400-e29b-41d4-a716-446655440003', 200, 0);

Dockerfile Base

packages/order-service/Dockerfile

FROM oven/bun:1.1-alpine AS base
WORKDIR /app

FROM base AS deps
COPY package.json bun.lockb ./
COPY packages/shared/package.json ./packages/shared/
COPY packages/order-service/package.json ./packages/order-service/
RUN bun install --frozen-lockfile

FROM base AS build
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN bun run build --filter=@orderflow/order-service

FROM base AS runtime
COPY --from=build /app/packages/order-service/dist ./dist
COPY --from=build /app/packages/shared/dist ./shared
EXPOSE 3000
CMD ["bun", "run", "dist/index.js"]

Resumen

Glosario

Monorepo

Definición: Estrategia de organización donde múltiples proyectos o paquetes relacionados se mantienen en un solo repositorio de código.

Por qué es importante: Facilita compartir código, mantener versiones consistentes, y hacer cambios que afectan múltiples servicios en un solo commit. Ideal para microservicios que comparten tipos y contratos.

Ejemplo práctico: OrderFlow tiene order-service, inventory-service, payment-service y shared todos en el mismo repositorio. Un cambio en un tipo compartido se refleja inmediatamente en todos los servicios.


Workspaces

Definición: Característica de gestores de paquetes (npm, yarn, bun) que permite manejar múltiples paquetes dentro de un monorepo, compartiendo dependencias y permitiendo referencias locales.

Por qué es importante: Evita duplicar dependencias comunes, permite que los paquetes se referencien entre sí fácilmente, y simplifica los scripts de desarrollo.

Ejemplo práctico: En package.json raíz definimos "workspaces": ["packages/*"]. Cada servicio puede usar "@orderflow/shared": "workspace:*" para referenciar el paquete compartido.


Docker

Definición: Plataforma que permite empaquetar aplicaciones en contenedores: unidades aisladas que incluyen el código, dependencias y configuración necesaria para ejecutarse.

Por qué es importante: Garantiza que la aplicación funcione igual en desarrollo, testing y producción. Cada servicio puede tener su propio entorno sin conflictos.

Ejemplo práctico: El Dockerfile de order-service define cómo construir una imagen que contiene Node.js, las dependencias y el código compilado, listo para ejecutarse en cualquier lugar.


Docker Compose

Definición: Herramienta para definir y ejecutar aplicaciones Docker multi-contenedor usando un archivo YAML que describe los servicios, redes y volúmenes.

Por qué es importante: Simplifica el desarrollo local de arquitecturas de microservicios. Con un solo comando (docker-compose up) levantas todos los servicios y sus dependencias.

Ejemplo práctico: El docker-compose.yml define PostgreSQL, Redis, y los cuatro servicios de OrderFlow, configurando las conexiones entre ellos automáticamente.


PostgreSQL

Definición: Sistema de base de datos relacional de código abierto, conocido por su robustez, extensibilidad y cumplimiento de estándares SQL.

Por qué es importante: Ofrece transacciones ACID confiables para cada microservicio. Aunque no podemos tener transacciones ACID entre servicios, sí las tenemos dentro de cada uno.

Ejemplo práctico: Cada servicio tiene sus tablas en la misma instancia de PostgreSQL pero operando de forma independiente. order-service usa la tabla orders, inventory-service usa inventory y reservations.


Redis

Definición: Base de datos en memoria de alta velocidad, usada comúnmente como cache, broker de mensajes, y almacén de datos de sesión.

Por qué es importante: Proporciona almacenamiento rápido para datos temporales, locks distribuidos para idempotencia, y puede usarse para comunicación entre servicios.

Ejemplo práctico: Los circuit breakers pueden guardar su estado en Redis para compartirlo entre múltiples instancias del mismo servicio. También se usa para locks distribuidos en compensaciones.


Zod

Definición: Librería TypeScript para definir esquemas de validación que generan automáticamente los tipos correspondientes.

Por qué es importante: Permite definir la estructura de los datos una sola vez y obtener tanto validación en tiempo de ejecución como tipos TypeScript, evitando inconsistencias.

Ejemplo práctico: CreateOrderSchema define con Zod qué campos debe tener una solicitud de crear pedido. Al validar, Zod rechaza datos inválidos. TypeScript conoce los tipos gracias a z.infer<typeof CreateOrderSchema>.


Schema (de Base de Datos)

Definición: Estructura que define las tablas, columnas, tipos de datos, índices y relaciones de una base de datos.

Por qué es importante: Un schema bien diseñado garantiza la integridad de los datos y optimiza las consultas. Los índices aceleran las búsquedas frecuentes.

Ejemplo práctico: La tabla orders tiene columnas id (UUID), customer_id (UUID), status (VARCHAR), etc. El índice idx_orders_customer acelera búsquedas por cliente.


Volumen (Docker)

Definición: Mecanismo de Docker para persistir datos generados por contenedores, sobreviviendo a la destrucción del contenedor.

Por qué es importante: Los contenedores son efímeros por diseño. Sin volúmenes, los datos de PostgreSQL se perderían cada vez que reiniciamos.

Ejemplo práctico: postgres_data:/var/lib/postgresql/data monta un volumen nombrado que persiste los datos de PostgreSQL aunque el contenedor se elimine.


Índice (de Base de Datos)

Definición: Estructura de datos que mejora la velocidad de las operaciones de búsqueda en una tabla de base de datos.

Por qué es importante: Sin índices, las búsquedas requieren escanear toda la tabla (O(n)). Con índices, las búsquedas son mucho más rápidas (típicamente O(log n)).

Ejemplo práctico: CREATE INDEX idx_orders_status ON orders(status) acelera consultas como SELECT * FROM orders WHERE status = 'pending', común para encontrar pedidos pendientes de procesar.


Multi-stage Build (Docker)

Definición: Técnica de Dockerfile donde se usan múltiples etapas (FROM) para separar el build de la imagen final, reduciendo el tamaño.

Por qué es importante: Las imágenes más pequeñas se descargan y despliegan más rápido, y tienen menor superficie de ataque de seguridad.

Ejemplo práctico: Etapa 1 instala dependencias, etapa 2 compila TypeScript, etapa 3 copia solo los archivos compilados sin las herramientas de build. La imagen final es mucho más pequeña.


← Capítulo 5: Diseño Resiliente | Capítulo 7: Order Service →