Capítulo 8: Inventory Service
Capítulo 8: Inventory Service
“Gestionar stock con precisión quirúrgica”
Introducción
El Inventory Service gestiona el inventario de productos. Su responsabilidad principal es asegurar que no vendamos más productos de los que tenemos disponibles.
En el contexto de sagas, este servicio es especialmente interesante porque:
- Debe reservar stock (no descontarlo inmediatamente) hasta que el pago se confirme.
- La compensación debe liberar la reserva si algo falla después.
- Las operaciones deben ser idempotentes para soportar reintentos.
Estructura del Servicio
packages/inventory-service/
├── src/
│ ├── domain/
│ │ ├── inventory.ts
│ │ └── reservation.ts
│ ├── repository/
│ │ └── inventory-repository.ts
│ ├── service/
│ │ └── inventory-service.ts
│ ├── api/
│ │ └── routes.ts
│ └── index.ts
└── package.json
Dominio
El dominio de inventario maneja dos conceptos clave:
- InventoryItem: Representa el stock de un producto, distinguiendo entre
available(disponible para nuevas ventas) yreserved(apartado para pedidos en proceso). - StockReservation: Representa una reserva de stock asociada a un pedido específico.
Esta separación es crucial: el stock reservado no está disponible para otros pedidos, pero tampoco se ha vendido aún. Solo cuando el pago se confirma, el stock reservado se “confirma” (se descuenta definitivamente).
packages/inventory-service/src/domain/inventory.ts
export class InventoryItem {
constructor(
public readonly productId: string,
public available: number,
public reserved: number
) {}
get totalStock(): number {
return this.available + this.reserved;
}
canReserve(quantity: number): boolean {
return this.available >= quantity;
}
reserve(quantity: number): void {
if (!this.canReserve(quantity)) {
throw new InsufficientStockError(this.productId, quantity, this.available);
}
this.available -= quantity;
this.reserved += quantity;
}
release(quantity: number): void {
if (this.reserved < quantity) {
throw new Error(`Cannot release ${quantity}, only ${this.reserved} reserved`);
}
this.reserved -= quantity;
this.available += quantity;
}
confirm(quantity: number): void {
if (this.reserved < quantity) {
throw new Error(`Cannot confirm ${quantity}, only ${this.reserved} reserved`);
}
this.reserved -= quantity;
}
}
export class InsufficientStockError extends Error {
constructor(
public productId: string,
public requested: number,
public available: number
) {
super(`Insufficient stock for ${productId}: requested ${requested}, available ${available}`);
this.name = 'InsufficientStockError';
}
}
packages/inventory-service/src/domain/reservation.ts
export type ReservationStatus = 'active' | 'confirmed' | 'cancelled';
export class StockReservation {
constructor(
public readonly id: string,
public readonly orderId: string,
public readonly items: ReservationItem[],
public status: ReservationStatus,
public readonly createdAt: Date
) {}
static create(orderId: string, items: ReservationItem[]): StockReservation {
return new StockReservation(
crypto.randomUUID(),
orderId,
items,
'active',
new Date()
);
}
confirm(): void {
if (this.status !== 'active') {
throw new Error(`Cannot confirm reservation in status ${this.status}`);
}
this.status = 'confirmed';
}
cancel(): void {
if (this.status !== 'active') {
throw new Error(`Cannot cancel reservation in status ${this.status}`);
}
this.status = 'cancelled';
}
isActive(): boolean {
return this.status === 'active';
}
}
export interface ReservationItem {
productId: string;
quantity: number;
}
Repositorio
El repositorio de inventario implementa operaciones que deben ser atómicas y consistentes. Usamos transacciones de base de datos con FOR UPDATE para bloquear los registros mientras los modificamos, evitando condiciones de carrera.
La operación clave reserveStock es un ejemplo de transacción de negocio local: todas las actualizaciones (verificar stock, actualizar inventario, crear reserva) ocurren en una sola transacción de base de datos.
packages/inventory-service/src/repository/inventory-repository.ts
import postgres from 'postgres';
import { InventoryItem } from '../domain/inventory.js';
import { StockReservation, type ReservationItem, type ReservationStatus } from '../domain/reservation.js';
export class InventoryRepository {
constructor(private sql: postgres.Sql) {}
async findByProductId(productId: string): Promise<InventoryItem | null> {
const [row] = await this.sql`
SELECT product_id, available, reserved
FROM inventory WHERE product_id = ${productId}
`;
if (!row) return null;
return new InventoryItem(row.product_id, row.available, row.reserved);
}
async updateInventory(item: InventoryItem): Promise<void> {
await this.sql`
UPDATE inventory
SET available = ${item.available}, reserved = ${item.reserved}
WHERE product_id = ${item.productId}
`;
}
async reserveStock(orderId: string, items: ReservationItem[]): Promise<StockReservation> {
return this.sql.begin(async (tx) => {
// Verificar y bloquear stock
for (const item of items) {
const [inv] = await tx`
SELECT product_id, available, reserved
FROM inventory
WHERE product_id = ${item.productId}
FOR UPDATE
`;
if (!inv || inv.available < item.quantity) {
throw new Error(`Insufficient stock for ${item.productId}`);
}
await tx`
UPDATE inventory
SET available = available - ${item.quantity},
reserved = reserved + ${item.quantity}
WHERE product_id = ${item.productId}
`;
}
// Crear reservación
const reservation = StockReservation.create(orderId, items);
await tx`
INSERT INTO stock_reservations (id, order_id, status, created_at)
VALUES (${reservation.id}, ${reservation.orderId}, ${reservation.status}, ${reservation.createdAt})
`;
for (const item of items) {
await tx`
INSERT INTO reservation_items (reservation_id, product_id, quantity)
VALUES (${reservation.id}, ${item.productId}, ${item.quantity})
`;
}
return reservation;
});
}
async findReservationByOrderId(orderId: string): Promise<StockReservation | null> {
const [row] = await this.sql`
SELECT id, order_id, status, created_at
FROM stock_reservations WHERE order_id = ${orderId}
`;
if (!row) return null;
const items = await this.sql`
SELECT product_id, quantity
FROM reservation_items WHERE reservation_id = ${row.id}
`;
return new StockReservation(
row.id,
row.order_id,
items.map(i => ({ productId: i.product_id, quantity: i.quantity })),
row.status as ReservationStatus,
row.created_at
);
}
async releaseReservation(reservationId: string): Promise<void> {
await this.sql.begin(async (tx) => {
const [reservation] = await tx`
SELECT id, status FROM stock_reservations
WHERE id = ${reservationId} FOR UPDATE
`;
if (!reservation || reservation.status !== 'active') {
return; // Idempotente: ya fue liberada o no existe
}
const items = await tx`
SELECT product_id, quantity FROM reservation_items
WHERE reservation_id = ${reservationId}
`;
for (const item of items) {
await tx`
UPDATE inventory
SET available = available + ${item.quantity},
reserved = reserved - ${item.quantity}
WHERE product_id = ${item.product_id}
`;
}
await tx`
UPDATE stock_reservations
SET status = 'cancelled'
WHERE id = ${reservationId}
`;
});
}
async confirmReservation(reservationId: string): Promise<void> {
await this.sql.begin(async (tx) => {
const [reservation] = await tx`
SELECT id, status FROM stock_reservations
WHERE id = ${reservationId} FOR UPDATE
`;
if (!reservation || reservation.status !== 'active') {
return; // Idempotente
}
const items = await tx`
SELECT product_id, quantity FROM reservation_items
WHERE reservation_id = ${reservationId}
`;
for (const item of items) {
await tx`
UPDATE inventory
SET reserved = reserved - ${item.quantity}
WHERE product_id = ${item.product_id}
`;
}
await tx`
UPDATE stock_reservations
SET status = 'confirmed'
WHERE id = ${reservationId}
`;
});
}
}
Servicio
packages/inventory-service/src/service/inventory-service.ts
import { InventoryRepository } from '../repository/inventory-repository.js';
import type { StockReservedEvent, StockReservationFailedEvent } from '@orderflow/shared';
interface ReserveStockInput {
orderId: string;
items: Array<{ productId: string; quantity: number }>;
}
export class InventoryService {
constructor(
private repository: InventoryRepository,
private publishEvent: (event: StockReservedEvent | StockReservationFailedEvent) => Promise<void>
) {}
async reserveStock(input: ReserveStockInput): Promise<{ reservationId: string }> {
try {
const reservation = await this.repository.reserveStock(input.orderId, input.items);
await this.publishEvent({
id: crypto.randomUUID(),
type: 'StockReserved',
timestamp: new Date(),
correlationId: input.orderId,
payload: {
orderId: input.orderId,
reservationId: reservation.id,
items: input.items
}
});
return { reservationId: reservation.id };
} catch (error) {
await this.publishEvent({
id: crypto.randomUUID(),
type: 'StockReservationFailed',
timestamp: new Date(),
correlationId: input.orderId,
payload: {
orderId: input.orderId,
reason: (error as Error).message
}
});
throw error;
}
}
async releaseStock(orderId: string): Promise<void> {
const reservation = await this.repository.findReservationByOrderId(orderId);
if (reservation && reservation.isActive()) {
await this.repository.releaseReservation(reservation.id);
}
}
async confirmStock(orderId: string): Promise<void> {
const reservation = await this.repository.findReservationByOrderId(orderId);
if (reservation && reservation.isActive()) {
await this.repository.confirmReservation(reservation.id);
}
}
async getStock(productId: string): Promise<{ available: number; reserved: number } | null> {
const item = await this.repository.findByProductId(productId);
if (!item) return null;
return { available: item.available, reserved: item.reserved };
}
async getReservation(orderId: string) {
return this.repository.findReservationByOrderId(orderId);
}
}
API
packages/inventory-service/src/api/routes.ts
import { Hono } from 'hono';
import { z } from 'zod';
import { zValidator } from '@hono/zod-validator';
import { InventoryService } from '../service/inventory-service.js';
const ReserveStockSchema = z.object({
orderId: z.string().uuid(),
items: z.array(z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive()
})).min(1)
});
export function createRoutes(inventoryService: InventoryService): Hono {
const app = new Hono();
app.post('/reservations', zValidator('json', ReserveStockSchema), async (c) => {
const input = c.req.valid('json');
try {
const result = await inventoryService.reserveStock(input);
return c.json(result, 201);
} catch (error) {
return c.json({ error: (error as Error).message }, 400);
}
});
app.delete('/reservations/:orderId', async (c) => {
await inventoryService.releaseStock(c.req.param('orderId'));
return c.json({ success: true });
});
app.post('/reservations/:orderId/confirm', async (c) => {
await inventoryService.confirmStock(c.req.param('orderId'));
return c.json({ success: true });
});
app.get('/reservations/:orderId', async (c) => {
const reservation = await inventoryService.getReservation(c.req.param('orderId'));
if (!reservation) {
return c.json({ error: 'Reservation not found' }, 404);
}
return c.json(reservation);
});
app.get('/inventory/:productId', async (c) => {
const stock = await inventoryService.getStock(c.req.param('productId'));
if (!stock) {
return c.json({ error: 'Product not found' }, 404);
}
return c.json(stock);
});
app.get('/health', (c) => c.json({ status: 'healthy' }));
return app;
}
Entry Point
packages/inventory-service/src/index.ts
import { serve } from '@hono/node-server';
import postgres from 'postgres';
import { InventoryRepository } from './repository/inventory-repository.js';
import { InventoryService } from './service/inventory-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 InventoryRepository(sql);
const publishEvent = async (event: unknown) => {
await fetch(`${ORCHESTRATOR_URL}/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(event)
});
};
const inventoryService = new InventoryService(repository, publishEvent);
const app = createRoutes(inventoryService);
console.log(`Inventory Service running on port ${PORT}`);
serve({ fetch: app.fetch, port: PORT });
Resumen
- Dominio con InventoryItem y StockReservation
- Reservación atómica con bloqueo FOR UPDATE
- Operaciones idempotentes para compensaciones seguras
- Eventos StockReserved y StockReservationFailed
- API para reservar, liberar y confirmar stock
Glosario
Reserva de Stock
Definición: Operación que aparta cierta cantidad de inventario para un pedido específico, haciéndolo no disponible para otros pedidos pero sin descontarlo definitivamente.
Por qué es importante: Permite que el inventario se mantenga preciso durante el proceso de la saga. Si el pago falla, la reserva se libera y el stock vuelve a estar disponible.
Ejemplo práctico: Hay 10 unidades disponibles. Un pedido reserva 3. Ahora hay 7 disponibles y 3 reservadas. Otro pedido solo puede usar las 7 disponibles.
Stock Disponible vs Reservado
Definición: El stock se divide en available (puede venderse) y reserved (apartado para pedidos en proceso). El stock total es la suma de ambos.
Por qué es importante: Evita sobreventa. Aunque 10 pedidos intenten comprar el último producto simultáneamente, solo uno puede reservarlo; los demás recibirán error de stock insuficiente.
Ejemplo práctico: totalStock = available + reserved. Si available = 7 y reserved = 3, hay 10 productos en total, pero solo 7 se pueden vender a nuevos clientes.
FOR UPDATE (Bloqueo Pesimista)
Definición: Cláusula SQL que bloquea las filas seleccionadas, impidiendo que otras transacciones las modifiquen hasta que la transacción actual termine.
Por qué es importante: Previene condiciones de carrera donde dos transacciones leen el mismo valor, ambas deciden que hay stock suficiente, y ambas lo descuentan, causando sobreventa.
Ejemplo práctico: SELECT * FROM inventory WHERE product_id = $1 FOR UPDATE bloquea ese registro. Otra transacción que intente seleccionar el mismo producto esperará hasta que la primera termine.
Bloqueo Pesimista vs Optimista
Definición: El bloqueo pesimista asume conflictos y bloquea preventivamente. El optimista asume que no habrá conflictos y verifica al final.
Por qué es importante: El pesimista es más seguro pero puede reducir la concurrencia. El optimista permite más concurrencia pero puede fallar más frecuentemente bajo alta carga.
Ejemplo práctico: FOR UPDATE es pesimista (bloquea inmediatamente). Una columna version que se verifica al actualizar es optimista (falla si alguien más modificó mientras tanto).
Condición de Carrera
Definición: Situación donde el resultado de una operación depende del orden de ejecución de procesos concurrentes, produciendo resultados impredecibles.
Por qué es importante: Las condiciones de carrera son bugs difíciles de detectar porque no siempre ocurren. Solo aparecen bajo ciertas circunstancias de timing.
Ejemplo práctico: Dos pedidos leen available = 1 simultáneamente. Ambos deciden que hay stock. Ambos descuentan. Resultado: available = -1 (sobreventa).
Transacción Local
Definición: Transacción de base de datos que opera dentro de un solo servicio/base de datos, garantizando propiedades ACID.
Por qué es importante: Aunque las sagas no tienen transacciones ACID globales, cada paso individual sí las tiene. Esto asegura que cada servicio mantiene sus datos consistentes.
Ejemplo práctico: reserveStock() usa sql.begin() para que la verificación de stock, actualización de inventario, y creación de reserva sean atómicas. O todas ocurren o ninguna.
Operación Idempotente
Definición: Operación que produce el mismo resultado sin importar cuántas veces se ejecute con los mismos parámetros de entrada.
Por qué es importante: En sistemas distribuidos, los mensajes pueden duplicarse. Las operaciones idempotentes garantizan que procesar el mismo mensaje múltiples veces no causa problemas.
Ejemplo práctico: releaseReservation() verifica si la reserva ya está cancelada antes de hacer nada. Si ya lo está, simplemente retorna sin error. Llamarla 5 veces es igual que llamarla 1 vez.
Liberación de Stock
Definición: Compensación que devuelve el stock reservado al estado disponible cuando la saga falla después de reservar pero antes de confirmar.
Por qué es importante: Sin liberación, el stock reservado quedaría “atrapado” indefinidamente, reduciendo artificialmente el inventario disponible.
Ejemplo práctico: El pago falló. La compensación llama a releaseStock(orderId). Los 3 productos reservados vuelven a estar disponibles para otros pedidos.
Confirmación de Stock
Definición: Operación que convierte una reserva en una venta definitiva, descontando el stock reservado del total.
Por qué es importante: Representa el momento donde el inventario cambia realmente. Antes era reserva (temporal); ahora es venta (permanente).
Ejemplo práctico: Después del pago exitoso, confirmStock(orderId) cambia el estado de la reserva a confirmed y reduce reserved (el stock ya no está en nuestro inventario, fue vendido).
Error de Stock Insuficiente
Definición: Error que ocurre cuando se intenta reservar más unidades de las que están disponibles.
Por qué es importante: Es un fallo de negocio legítimo, no un error técnico. La saga debe compensar los pasos anteriores y notificar al cliente que no hay stock.
Ejemplo práctico: available = 5, intento reservar 10. InsufficientStockError se lanza con detalles: producto, cantidad solicitada, cantidad disponible. El orquestador inicia la compensación.