Arquitectura de Software

Domain-Driven Design: Proyecto práctico, construyendo una aplicación completa de principio a fin

Integra la colección construyendo una API de pedidos con TypeScript, Express, Zod y SQLite. Incluye los archivos del proyecto, reglas del dominio, eventos, control de versiones, pruebas con Vitest y comandos para ejecutarla y verificarla localmente.

Cargando tus opciones de aprendizaje…

Una tienda necesita dejar de gestionar pedidos mediante cambios manuales en una tabla. Quiere crear borradores, confirmarlos cuando tengan productos y cancelar los que todavía no se han confirmado. También necesita saber qué confirmaciones se registraron, incluso después de reiniciar la aplicación.

Vamos a construir esa aplicación local: una API HTTP, un modelo de dominio, persistencia SQLite, procesamiento de eventos y pruebas. El alcance es un backend educativo completo para esas operaciones; no una tienda con pagos, cuentas de usuario y catálogo.

Los archivos siguientes forman una propuesta nueva y autocontenida. No proceden del repositorio de Código con criterio. No se han instalado sus dependencias ni ejecutado su compilación o sus pruebas durante la preparación del artículo. Se incluyen las verificaciones para reproducirlo y comprobarlo localmente, sin atribuirles resultados que todavía no existen.

1. Descubrimiento reducido: acordar qué estamos construyendo

Imaginemos una conversación con la persona que administra los pedidos:

—¿Confirmar significa que el cliente ya pagó?

—En este ejercicio significa que el pedido queda cerrado para su preparación. El pago pertenece a otro proceso.

—¿Podemos confirmar un pedido vacío?

—No. Puede guardarse vacío como borrador, pero necesita productos antes de confirmarse.

—¿Qué ocurre si alguien vuelve a confirmar?

—Se rechaza. No queremos que una nueva solicitud represente una segunda confirmación.

El resultado del taller reducido es este acuerdo específico del proyecto:

  • Un pedido nace en DRAFT y contiene entre cero y veinte líneas.
  • Cada línea identifica un producto mediante un texto no vacío de hasta ochenta caracteres y una cantidad entera entre uno y cien.
  • No se repite el mismo identificador de producto en dos líneas. Se eliminan espacios exteriores antes de compararlos.
  • Solo DRAFT puede pasar a CONFIRMED y debe contener al menos una línea.
  • Solo DRAFT puede pasar a CANCELLED. Un borrador vacío también puede cancelarse.
  • CONFIRMED y CANCELLED son estados finales para este MVP.
  • La confirmación repetida se rechaza; no se interpreta como éxito idempotente.
  • La versión persistida detecta escrituras basadas en un estado obsoleto.
  • Cada confirmación aceptada genera un evento y un registro consultable en la misma transacción.

Son decisiones del ejemplo, no reglas universales de comercio electrónico. El vocabulario acordado evita mezclar «confirmado», «pagado» y «despachado».

El proceso puede representarse así:

Operador -> Crear pedido -> DRAFT
                              |
                  +-----------+-----------+
                  |                       |
          Confirmar con líneas         Cancelar
                  |                       |
              CONFIRMED               CANCELLED
                  |
            OrderConfirmed
                  |
     Registro local de confirmación

No implementaremos edición de líneas, precios, pago, disponibilidad ni despacho. Se crea un borrador con sus líneas definitivas para mantener el proyecto pequeño. Eso no deja incompleta ninguna de las operaciones prometidas.

2. Contextos y decisiones del MVP

Identificamos Sales como dueño del pedido. Catalog sería dueño de la descripción comercial del producto; Inventory, de la disponibilidad; Billing, de facturación. En esta aplicación solo implementamos Sales: productId es una referencia y no una importación de entidades ajenas.

Los otros contextos son candidatos del descubrimiento, no servicios que debamos crear. No consultaremos su base de datos ni fingiremos que conocemos sus reglas. El contrato de confirmación que produciremos permite discutir una futura integración, sin implementar consumidores imaginarios.

Usamos una estructura preparada para un monolito modular porque no necesitamos despliegues ni transacciones distribuidas. La responsabilidad de Sales justifica un modelo explícito; autenticación, correo y otros servicios genéricos quedan fuera del ejercicio. No necesitamos Shared Kernel, un bus genérico ni un contenedor de inyección para resolver esta operación.

La decisión de consistencia es local y verificable:

HTTP -> aplicación -> Order
             |
       OrderRepository
             |
        adaptador SQLite
             |
     BEGIN IMMEDIATE
       guardar versión y estado
       procesar OrderConfirmed
       guardar contrato de confirmación
     COMMIT

El procesamiento del evento solo produce datos locales dentro de la transacción. No envía correo ni llama por HTTP a otro sistema. Así evitamos presentar un efecto externo como si SQLite pudiera revertirlo.

3. Entorno, instalación y estructura

Necesitas Node.js 24 y npm. Elegimos node:sqlite para evitar un paquete nativo adicional; DatabaseSync ofrece una conexión SQLite con operaciones síncronas. Este enfoque mantiene el ejemplo compacto, pero bloquea el hilo de JavaScript mientras trabaja: no lo extrapoles a consultas largas o a una API de alta concurrencia sin evaluar alternativas. SQLite en Node.js 24.

Usaremos Express 5, Zod 4, TypeScript 5.9 y Vitest 4. Node 24 satisface los requisitos de las ramas de Express y Vitest seleccionadas. La guía enlazada de Vitest corresponde a la versión 4, no presupone que sea la última disponible. Express: instalación, Vitest 4: instalación.

Crea una carpeta nueva:

mkdir ddd-orders-workshop
cd ddd-orders-workshop
node --version
npm --version

Copia los archivos que siguen respetando sus rutas. Los comandos no modifican ningún repositorio existente.

ddd-orders-workshop/
  package.json
  tsconfig.json
  tsconfig.check.json
  .gitignore
  src/
    main.ts
    sales/
      domain/order.ts
      application/orders.ts
      infrastructure/sqlite-orders.ts
      presentation/http.ts
  tests/
    order.test.ts
    sqlite.test.ts
    http.test.ts

package.json

{
  "name": "ddd-orders-workshop",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": { "node": ">=24 <25" },
  "scripts": {
    "build": "tsc",
    "typecheck": "tsc -p tsconfig.check.json",
    "start": "node dist/main.js",
    "test": "vitest run"
  },
  "dependencies": { "express": "^5.1.0", "zod": "^4.0.0" },
  "devDependencies": {
    "@types/express": "^5.0.0",
    "@types/node": "^24.0.0",
    "typescript": "~5.9.3",
    "vitest": "^4.0.0"
  }
}

Los rangos limitan las ramas mayores, pero la primera resolución de dependencias puede cambiar con el tiempo. npm install generará package-lock.json: consérvalo junto al proyecto. Las instalaciones posteriores deben usar npm ci para reproducir esa resolución. Esto no sustituye revisar avisos de seguridad antes de reutilizarlo en un sistema real.

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "rootDir": "src",
    "outDir": "dist"
  },
  "include": ["src/**/*.ts"]
}

tsconfig.check.json

{
  "extends": "./tsconfig.json",
  "compilerOptions": { "rootDir": ".", "noEmit": true },
  "include": ["src/**/*.ts", "tests/**/*.ts"]
}

Separamos la compilación de src del chequeo de tipos de src y tests. Vitest ejecuta pruebas, pero no reemplaza ese chequeo estático.

.gitignore

node_modules/
dist/
data/
coverage/

4. Modelo: el pedido protege sus transiciones

Quantity representa un valor válido y Order contiene el comportamiento. La fábrica estática create fija el estado inicial; restore reconstruye un snapshot interno sin emitir eventos nuevos.

No añadimos un Domain Service porque ninguna regla necesita coordinar conceptos que no pertenezcan al pedido. Aplicar la colección significa escoger herramientas justificadas, no introducir todos sus patrones en cada archivo.

src/sales/domain/order.ts

export type Status = "DRAFT" | "CONFIRMED" | "CANCELLED";
export type LineInput = Readonly<{ productId: string; quantity: number }>;
export type Snapshot = Readonly<{
  id: string;
  status: Status;
  lines: readonly LineInput[];
  version: number;
}>;
export type OrderConfirmed = Readonly<{
  type: "OrderConfirmed";
  eventId: string;
  orderId: string;
  occurredAt: string;
}>;

export class RuleViolation extends Error {}

export class Quantity {
  readonly #value: number;
  constructor(value: number) {
    if (!Number.isSafeInteger(value) || value < 1 || value > 100) {
      throw new RuleViolation("INVALID_QUANTITY");
    }
    this.#value = value;
  }
  get value(): number { return this.#value; }
}

export class Order {
  #status: Status;
  #version: number;
  readonly #lines: readonly LineInput[];
  #events: OrderConfirmed[] = [];

  private constructor(snapshot: Snapshot) {
    this.#status = snapshot.status;
    this.#version = snapshot.version;
    this.id = snapshot.id;
    if (snapshot.lines.length > 20) {
      throw new RuleViolation("TOO_MANY_LINES");
    }
    const seen = new Set<string>();
    this.#lines = snapshot.lines.map((line) => {
      const productId = line.productId.trim();
      if (!productId || productId.length > 80 || seen.has(productId)) {
        throw new RuleViolation("INVALID_OR_DUPLICATE_PRODUCT");
      }
      seen.add(productId);
      return Object.freeze({
        productId,
        quantity: new Quantity(line.quantity).value,
      });
    });
  }

  readonly id: string;

  static create(id: string, lines: readonly LineInput[]): Order {
    return new Order({ id, lines, status: "DRAFT", version: 0 });
  }

  // Solo para snapshots internos leídos desde nuestra persistencia.
  static restore(snapshot: Snapshot): Order { return new Order(snapshot); }

  confirm(eventId: string, occurredAt: string): void {
    if (this.#status !== "DRAFT") {
      throw new RuleViolation("ORDER_NOT_DRAFT");
    }
    if (this.#lines.length === 0) {
      throw new RuleViolation("ORDER_EMPTY");
    }
    this.#status = "CONFIRMED";
    this.#events.push(Object.freeze({
      type: "OrderConfirmed",
      eventId,
      orderId: this.id,
      occurredAt,
    }));
  }

  cancel(): void {
    if (this.#status !== "DRAFT") {
      throw new RuleViolation("ORDER_NOT_DRAFT");
    }
    this.#status = "CANCELLED";
  }

  snapshot(): Snapshot {
    return {
      id: this.id,
      status: this.#status,
      version: this.#version,
      lines: this.#lines.map((line) => ({ ...line })),
    };
  }

  pendingEvents(): readonly OrderConfirmed[] { return [...this.#events]; }

  // El adaptador lo llama únicamente después de COMMIT.
  acknowledgePersistence(): void {
    this.#version += 1;
    this.#events = [];
  }
}

Los getters y las copias permiten consultar sin compartir el array interno. El estado usa campos privados de JavaScript. acknowledgePersistence es un protocolo interno del adaptador: no se expone como operación HTTP ni sustituye un método de negocio.

Antes de guardar, version vale cero. La primera persistencia la eleva a uno; cada escritura aceptada incrementa esa versión. restore confía en datos escritos por nuestra propia aplicación. Un importador de archivos o una herramienta administrativa necesitaría su propia validación de snapshots.

El identificador de producto tiene un máximo de ochenta caracteres tanto en HTTP como en el dominio. El transporte protege la forma de la solicitud; el dominio conserva esa regla aunque reciba una llamada desde otro adaptador.

5. Casos de uso y contrato de integración

Para reducir archivos pequeños agrupamos los casos de uso en Orders. Cada método coordina una operación; no reproduce las condiciones de estado. En un proyecto mayor se pueden separar CreateOrder, ConfirmOrder y CancelOrder sin alterar las reglas.

El evento interno OrderConfirmed se convierte mediante un handler en sales.order-confirmed.v1. El contrato contiene identificador, fecha y pedido confirmado. No exporta el agregado ni convierte automáticamente todos sus campos en una API pública.

src/sales/application/orders.ts

import {
  Order, type LineInput, type OrderConfirmed, type Snapshot,
} from "../domain/order.js";

export class NotFound extends Error {}
export class Conflict extends Error {}

export type ConfirmationV1 = Readonly<{
  id: string;
  type: "sales.order-confirmed.v1";
  occurredAt: string;
  data: Readonly<{ orderId: string }>;
}>;

// Contrato público deliberadamente separado del evento interno.
export function handleOrderConfirmed(event: OrderConfirmed): ConfirmationV1 {
  return {
    id: event.eventId,
    type: "sales.order-confirmed.v1",
    occurredAt: event.occurredAt,
    data: { orderId: event.orderId },
  };
}

export interface OrderRepository {
  find(id: string): Order | null;
  save(order: Order): void;
  confirmations(id: string): readonly ConfirmationV1[];
}

export class Orders {
  constructor(
    private readonly repository: OrderRepository,
    private readonly newId: () => string,
    private readonly now: () => string,
  ) {}

  create(lines: readonly LineInput[]): Snapshot {
    const order = Order.create(this.newId(), lines);
    this.repository.save(order);
    return order.snapshot();
  }

  private require(id: string): Order {
    const order = this.repository.find(id);
    if (!order) throw new NotFound("ORDER_NOT_FOUND");
    return order;
  }

  get(id: string): Snapshot { return this.require(id).snapshot(); }

  confirm(id: string): Snapshot {
    const order = this.require(id);
    order.confirm(this.newId(), this.now());
    this.repository.save(order);
    return order.snapshot();
  }

  cancel(id: string): Snapshot {
    const order = this.require(id);
    order.cancel();
    this.repository.save(order);
    return order.snapshot();
  }

  confirmations(id: string): readonly ConfirmationV1[] {
    this.require(id);
    return this.repository.confirmations(id);
  }
}

El reloj y el generador de identificadores se inyectan desde fuera. El dominio recibe esos valores y no necesita importar herramientas de Node.

La lectura confirmations está en el mismo puerto para mantener corto el ejercicio. Con más consultas podría separarse en un puerto de lectura. Hacerlo aquí no cambiaría el comportamiento ni la consistencia.

6. Persistencia real y procesamiento atómico del evento

Guardamos el snapshot como JSON y mantenemos una columna version para la escritura condicional. Es una decisión educativa: simplifica la reconstrucción, a cambio de limitar consultas SQL sobre líneas. Una aplicación con búsquedas o informes complejos podría necesitar tablas normalizadas.

src/sales/infrastructure/sqlite-orders.ts

import { DatabaseSync } from "node:sqlite";
import { Order, type Snapshot } from "../domain/order.js";
import {
  Conflict, handleOrderConfirmed,
  type ConfirmationV1, type OrderRepository,
} from "../application/orders.js";

export function initialize(db: DatabaseSync): void {
  db.exec(`
    PRAGMA foreign_keys = ON;
    PRAGMA busy_timeout = 3000;
    CREATE TABLE IF NOT EXISTS orders (
      id TEXT PRIMARY KEY,
      snapshot TEXT NOT NULL,
      version INTEGER NOT NULL CHECK(version >= 1)
    );
    CREATE TABLE IF NOT EXISTS confirmations (
      id TEXT PRIMARY KEY,
      order_id TEXT NOT NULL REFERENCES orders(id),
      payload TEXT NOT NULL
    );
  `);
}

export class SqliteOrders implements OrderRepository {
  constructor(private readonly db: DatabaseSync) {}

  find(id: string): Order | null {
    const row = this.db.prepare(
      "SELECT snapshot FROM orders WHERE id = ?",
    ).get(id);
    if (!row) return null;
    return Order.restore(JSON.parse(String(row.snapshot)) as Snapshot);
  }

  save(order: Order): void {
    const snapshot = order.snapshot();
    const next = { ...snapshot, version: snapshot.version + 1 };
    const events = order.pendingEvents();
    this.db.exec("BEGIN IMMEDIATE");
    try {
      if (snapshot.version === 0) {
        const result = this.db.prepare(
          "INSERT OR IGNORE INTO orders(id, snapshot, version) VALUES(?, ?, 1)",
        ).run(snapshot.id, JSON.stringify(next));
        if (Number(result.changes) !== 1) {
          throw new Conflict("ORDER_ALREADY_EXISTS");
        }
      } else {
        const result = this.db.prepare(
          "UPDATE orders SET snapshot = ?, version = ? WHERE id = ? AND version = ?",
        ).run(JSON.stringify(next), next.version, snapshot.id, snapshot.version);
        if (Number(result.changes) !== 1) {
          throw new Conflict("ORDER_VERSION_CONFLICT");
        }
      }

      for (const event of events) {
        const contract = handleOrderConfirmed(event);
        this.db.prepare(
          "INSERT INTO confirmations(id, order_id, payload) VALUES(?, ?, ?)",
        ).run(contract.id, snapshot.id, JSON.stringify(contract));
      }
      this.db.exec("COMMIT");
    } catch (error) {
      this.db.exec("ROLLBACK");
      throw error;
    }
    order.acknowledgePersistence();
  }

  confirmations(id: string): readonly ConfirmationV1[] {
    return this.db.prepare(
      "SELECT payload FROM confirmations WHERE order_id = ? ORDER BY rowid",
    ).all(id).map((row) =>
      JSON.parse(String(row.payload)) as ConfirmationV1,
    );
  }
}

El adaptador implementa el contrato de aplicación y conoce SQLite. Order no conoce SQL. La composición exterior será quien decida usar esta implementación.

Si dos copias cargan version 1, solo una puede actualizarla a 2. La segunda escritura afecta cero filas y se rechaza con conflicto. BEGIN IMMEDIATE ayuda a organizar la escritura local; no reemplaza la condición de versión.

El handler se ejecuta después de actualizar el pedido y antes de COMMIT. Si insertar su resultado falla, ROLLBACK revierte también el cambio de estado. Los eventos pendientes solo se limpian después de confirmar la transacción.

La tabla confirmations conserva el contrato versionado y permite consultarlo. No es un sistema de entrega a otros contextos: no tiene consumidor, confirmación de recepción ni reintentos. Tampoco estamos reconstruyendo pedidos desde eventos, por lo que esto no es Event Sourcing. Si apareciera una integración externa, habría que diseñar su entrega y su fiabilidad como una capacidad adicional.

7. HTTP: validar y traducir resultados

Zod valida la entrada HTTP mediante safeParse, que permite distinguir éxito y error sin lanzar una excepción por cada solicitud inválida. Eso no desplaza las reglas del agregado al esquema. Uso básico de Zod.

El contrato del ejercicio asigna 400 a entradas inválidas, 404 a pedidos inexistentes, 409 a conflictos de versión y 422 a reglas del pedido incumplidas. Son decisiones documentadas de esta API.

src/sales/presentation/http.ts

import express, {
  type ErrorRequestHandler, type RequestHandler,
} from "express";
import { z } from "zod";
import { Conflict, NotFound, Orders } from "../application/orders.js";
import { RuleViolation } from "../domain/order.js";

const createBody = z.object({
  lines: z.array(z.object({
    productId: z.string().trim().min(1).max(80),
    quantity: z.number().int().min(1).max(100),
  }).strict()).max(20),
}).strict();
const params = z.object({ id: z.string().uuid() });

export function createApp(orders: Orders) {
  const app = express();
  app.disable("x-powered-by");
  app.use(express.json({ limit: "16kb" }));

  app.post("/orders", (request, response) => {
    const result = createBody.safeParse(request.body);
    if (!result.success) {
      response.status(400).json({ error: "INVALID_BODY" });
      return;
    }
    const order = orders.create(result.data.lines);
    response.location("/orders/" + order.id).status(201).json(order);
  });

  const validId: RequestHandler = (request, response, next) => {
    if (!params.safeParse(request.params).success) {
      response.status(400).json({ error: "INVALID_ID" });
      return;
    }
    next();
  };

  app.get("/orders/:id", validId, (request, response) => {
    response.json(orders.get(String(request.params.id)));
  });
  app.post("/orders/:id/confirm", validId, (request, response) => {
    response.json(orders.confirm(String(request.params.id)));
  });
  app.post("/orders/:id/cancel", validId, (request, response) => {
    response.json(orders.cancel(String(request.params.id)));
  });
  app.get("/orders/:id/confirmations", validId, (request, response) => {
    response.json(orders.confirmations(String(request.params.id)));
  });

  app.use((_request, response) => {
    response.status(404).json({ error: "ROUTE_NOT_FOUND" });
  });

  const errors: ErrorRequestHandler = (error, _request, response, _next) => {
    if (error instanceof NotFound) {
      response.status(404).json({ error: error.message });
    } else if (error instanceof Conflict) {
      response.status(409).json({ error: error.message });
    } else if (error instanceof RuleViolation) {
      response.status(422).json({ error: error.message });
    } else if (error?.type === "entity.parse.failed") {
      response.status(400).json({ error: "INVALID_JSON" });
    } else if (error?.type === "entity.too.large") {
      response.status(413).json({ error: "BODY_TOO_LARGE" });
    } else {
      console.error("Unhandled request error");
      response.status(500).json({ error: "INTERNAL_ERROR" });
    }
  };
  app.use(errors);
  return app;
}

Las rutas de confirmación y cancelación no reciben cambios arbitrarios del pedido; su intención está expresada en la operación. Un cliente no puede enviar status para saltarse el agregado.

Este servidor local no implementa usuarios ni autorización. Escuchará únicamente en 127.0.0.1: no lo publiques ni lo conviertas en accesible externamente como si fuera una API productiva. La ausencia de esas capacidades es parte del alcance local, no una recomendación para aplicaciones reales.

8. Composición manual y arranque

src/main.ts

import { mkdirSync } from "node:fs";
import { randomUUID } from "node:crypto";
import { DatabaseSync } from "node:sqlite";
import { Orders } from "./sales/application/orders.js";
import { initialize, SqliteOrders } from "./sales/infrastructure/sqlite-orders.js";
import { createApp } from "./sales/presentation/http.js";

mkdirSync("data", { recursive: true });
const db = new DatabaseSync("data/orders.sqlite");
initialize(db);
const orders = new Orders(
  new SqliteOrders(db), randomUUID, () => new Date().toISOString(),
);
const server = createApp(orders).listen(3000, "127.0.0.1", () => {
  console.log("Orders API: http://127.0.0.1:3000");
});
for (const signal of ["SIGINT", "SIGTERM"] as const) {
  process.once(signal, () => {
    server.close(() => {
      db.close();
      process.exit(0);
    });
  });
}

La conexión se abre una vez y se cierra al detener el servidor. La base queda en data/orders.sqlite relativa a la carpeta desde la que arrancas. Ejecuta siempre npm start en la raíz del proyecto para usar el mismo archivo.

Después de copiar todos los archivos:

npm install
npm run typecheck
npm test
npm run build
npm start

La primera línea descarga dependencias en esta carpeta nueva. No se ha ejecutado durante la elaboración del artículo. Ante un error detente y revisa su salida antes de seguir; los comandos se muestran por separado para que puedas identificarlo.

Si tu compilación no reconoce node:sqlite, comprueba que ejecutas Node 24 y que la instalación resolvió @types/node de la rama 24. Si el puerto 3000 está ocupado, detén el proceso anterior o cambia explícitamente el puerto del ejemplo.

9. Pruebas del dominio, persistencia y recorrido HTTP

Las siguientes suites están completas como archivos del proyecto. Sus resultados deben obtenerse ejecutando npm test en tu entorno; no se presentan aquí como pruebas aprobadas.

tests/order.test.ts

import { describe, expect, it } from "vitest";
import { Order, Quantity } from "../src/sales/domain/order.js";

const line = { productId: "book-1", quantity: 2 };
const eventId = "event-1";
const time = "2026-10-23T12:00:00.000Z";

describe("Order", () => {
  it("crea DRAFT, confirma y registra un evento", () => {
    const order = Order.create("order-1", [line]);
    expect(order.snapshot().status).toBe("DRAFT");
    order.confirm(eventId, time);
    expect(order.snapshot().status).toBe("CONFIRMED");
    expect(order.pendingEvents()).toEqual([{
      type: "OrderConfirmed", eventId, orderId: "order-1", occurredAt: time,
    }]);
  });

  it("rechaza vacío sin cambiar estado ni emitir eventos", () => {
    const order = Order.create("order-1", []);
    expect(() => order.confirm(eventId, time)).toThrow("ORDER_EMPTY");
    expect(order.snapshot().status).toBe("DRAFT");
    expect(order.pendingEvents()).toEqual([]);
  });

  it("rechaza repetición sin duplicar eventos", () => {
    const order = Order.create("order-1", [line]);
    order.confirm(eventId, time);
    expect(() => order.confirm("event-2", time)).toThrow("ORDER_NOT_DRAFT");
    expect(order.pendingEvents()).toHaveLength(1);
  });

  it("no permite confirmar un pedido cancelado", () => {
    const order = Order.create("order-1", [line]);
    order.cancel();
    expect(() => order.confirm(eventId, time)).toThrow("ORDER_NOT_DRAFT");
    expect(order.snapshot().status).toBe("CANCELLED");
  });

  it("solo permite cancelar DRAFT", () => {
    const order = Order.create("order-1", [line]);
    order.confirm(eventId, time);
    expect(() => order.cancel()).toThrow("ORDER_NOT_DRAFT");
    const cancelled = Order.create("order-2", []);
    cancelled.cancel();
    expect(() => cancelled.cancel()).toThrow("ORDER_NOT_DRAFT");
  });

  it.each([0, -1, 1.5, 101, NaN])("rechaza cantidad %s", (value) => {
    expect(() => new Quantity(value)).toThrow("INVALID_QUANTITY");
  });

  it("acepta los extremos válidos", () => {
    expect(new Quantity(1).value).toBe(1);
    expect(new Quantity(100).value).toBe(100);
  });

  it("rechaza productos vacíos, repetidos y más de 20 líneas", () => {
    expect(() => Order.create("a", [{ ...line, productId: " " }]))
      .toThrow("INVALID_OR_DUPLICATE_PRODUCT");
    expect(() => Order.create("a", [{ ...line, productId: "x".repeat(81) }]))
      .toThrow("INVALID_OR_DUPLICATE_PRODUCT");
    expect(() => Order.create("a", [line, line]))
      .toThrow("INVALID_OR_DUPLICATE_PRODUCT");
    expect(() => Order.create("a", Array.from({ length: 21 }, (_, i) => ({
      productId: "p-" + i, quantity: 1,
    })))).toThrow("TOO_MANY_LINES");
  });

  it("no expone referencias mutables de las líneas", () => {
    const input = [{ ...line }];
    const order = Order.create("a", input);
    input[0].quantity = 99;
    const snapshot = order.snapshot();
    expect(snapshot.lines[0].quantity).toBe(2);
    expect(order.snapshot().lines[0].quantity).toBe(2);
  });
});

Estas pruebas distinguen reglas: vacío, repetición, cancelado, cantidades y protección de referencias. Cada rechazo conserva el estado anterior y evita eventos adicionales.

tests/sqlite.test.ts

import { DatabaseSync } from "node:sqlite";
import { afterEach, beforeEach, expect, it } from "vitest";
import { Order } from "../src/sales/domain/order.js";
import { initialize, SqliteOrders } from "../src/sales/infrastructure/sqlite-orders.js";

let db: DatabaseSync;
let repository: SqliteOrders;
beforeEach(() => {
  db = new DatabaseSync(":memory:");
  initialize(db);
  repository = new SqliteOrders(db);
});
afterEach(() => db.close());

it("persiste estado y contrato, y limpia eventos tras COMMIT", () => {
  const order = Order.create("o-1", [{ productId: "p-1", quantity: 1 }]);
  repository.save(order);
  order.confirm("e-1", "2026-10-23T12:00:00.000Z");
  repository.save(order);
  expect(repository.find("o-1")?.snapshot().status).toBe("CONFIRMED");
  expect(repository.confirmations("o-1")).toEqual([{
    id: "e-1",
    type: "sales.order-confirmed.v1",
    occurredAt: "2026-10-23T12:00:00.000Z",
    data: { orderId: "o-1" },
  }]);
  expect(order.pendingEvents()).toEqual([]);
});

it("rechaza un escritor con versión obsoleta", () => {
  repository.save(Order.create("o-1", [{ productId: "p-1", quantity: 1 }]));
  const first = repository.find("o-1")!;
  const stale = repository.find("o-1")!;
  first.confirm("e-1", "2026-10-23T12:00:00.000Z");
  repository.save(first);
  stale.cancel();
  expect(() => repository.save(stale)).toThrow("ORDER_VERSION_CONFLICT");
  expect(repository.find("o-1")?.snapshot().status).toBe("CONFIRMED");
  expect(repository.confirmations("o-1")).toHaveLength(1);
});

it("revierte el estado si falla el procesamiento del evento", () => {
  const order = Order.create("o-1", [{ productId: "p-1", quantity: 1 }]);
  repository.save(order);
  db.exec(`
    CREATE TRIGGER reject_confirmation BEFORE INSERT ON confirmations
    BEGIN SELECT RAISE(ABORT, 'TEST_FAILURE'); END;
  `);
  order.confirm("e-1", "2026-10-23T12:00:00.000Z");
  expect(() => repository.save(order)).toThrow("TEST_FAILURE");
  expect(repository.find("o-1")?.snapshot().status).toBe("DRAFT");
  expect(repository.confirmations("o-1")).toEqual([]);
  expect(order.pendingEvents()).toHaveLength(1);
});

Usamos SQLite real en memoria, no un repositorio simulado. La prueba de conflicto carga dos agregados independientes. La prueba con trigger provoca un fallo controlado para comprobar que el estado no queda confirmado sin su registro. Cada prueba crea y cierra su propia conexión.

La instancia en memoria es desechable, pero el adaptador es el mismo utilizado con archivo en el servidor. La persistencia después de reiniciar se comprobará también mediante HTTP.

tests/http.test.ts

import { randomUUID } from "node:crypto";
import { once } from "node:events";
import type { Server } from "node:http";
import { DatabaseSync } from "node:sqlite";
import type { AddressInfo } from "node:net";
import { afterEach, beforeEach, expect, it } from "vitest";
import { Orders } from "../src/sales/application/orders.js";
import { initialize, SqliteOrders } from "../src/sales/infrastructure/sqlite-orders.js";
import { createApp } from "../src/sales/presentation/http.js";

let db: DatabaseSync;
let server: Server;
let origin: string;
beforeEach(async () => {
  db = new DatabaseSync(":memory:");
  initialize(db);
  const orders = new Orders(
    new SqliteOrders(db), randomUUID, () => "2026-10-23T12:00:00.000Z",
  );
  server = createApp(orders).listen(0, "127.0.0.1");
  await once(server, "listening");
  origin = "http://127.0.0.1:" + (server.address() as AddressInfo).port;
});
afterEach(async () => {
  await new Promise<void>((resolve, reject) => {
    server.close((error) => error ? reject(error) : resolve());
    server.closeAllConnections();
  });
  db.close();
});

function post(path: string, body?: unknown) {
  return fetch(origin + path, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
}

it("recorre HTTP, dominio, SQLite y consulta de confirmaciones", async () => {
  const created = await post("/orders", {
    lines: [{ productId: "book-1", quantity: 2 }],
  });
  expect(created.status).toBe(201);
  const order = await created.json() as { id: string };
  const confirmed = await post("/orders/" + order.id + "/confirm");
  expect(confirmed.status).toBe(200);
  expect((await confirmed.json()).status).toBe("CONFIRMED");
  const repeated = await post("/orders/" + order.id + "/confirm");
  expect(repeated.status).toBe(422);
  expect(await repeated.json()).toEqual({ error: "ORDER_NOT_DRAFT" });
  const events = await fetch(origin + "/orders/" + order.id + "/confirmations");
  expect((await events.json())).toHaveLength(1);
});

it("rechaza JSON inválido, cantidades inválidas e identificadores inválidos", async () => {
  const malformed = await fetch(origin + "/orders", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: "{",
  });
  expect(malformed.status).toBe(400);
  expect((await post("/orders", {
    lines: [{ productId: "p", quantity: 0 }],
  })).status).toBe(400);
  expect((await fetch(origin + "/orders/not-a-uuid")).status).toBe(400);
  expect((await fetch(origin + "/orders/" + randomUUID())).status).toBe(404);
});

it("rechaza vacío y permite cancelar un borrador", async () => {
  const created = await post("/orders", { lines: [] });
  const order = await created.json() as { id: string };
  expect((await post("/orders/" + order.id + "/confirm")).status).toBe(422);
  const cancelled = await post("/orders/" + order.id + "/cancel");
  expect((await cancelled.json()).status).toBe("CANCELLED");
  expect((await post("/orders/" + order.id + "/confirm")).status).toBe(422);
});

Estas pruebas abren un puerto local asignado por el sistema, envían peticiones reales y verifican respuestas. Al terminar cierran servidor y conexión. No requieren un servicio externo ni una base compartida.

10. Probarlo manualmente y comprobar la persistencia

Con npm start activo, abre otra terminal. Usa curl en Bash o curl.exe en PowerShell para evitar diferencias con alias.

Primero crea un pedido:

curl -i -X POST http://127.0.0.1:3000/orders \
  -H "Content-Type: application/json" \
  --data '{"lines":[{"productId":"book-1","quantity":2}]}'

La respuesta esperada es 201 y un cuerpo con esta forma. El identificador se genera en cada ejecución:

{
  "id": "IDENTIFICADOR_GENERADO",
  "status": "DRAFT",
  "version": 1,
  "lines": [{ "productId": "book-1", "quantity": 2 }]
}

Los siguientes comandos usan ID como marcador: sustitúyelo por el UUID real, sin llaves adicionales.

curl -i -X POST http://127.0.0.1:3000/orders/ID/confirm
curl -i http://127.0.0.1:3000/orders/ID
curl -i http://127.0.0.1:3000/orders/ID/confirmations

La confirmación debe devolver 200, status CONFIRMED y version 2. La consulta de confirmaciones devuelve un array de un elemento:

[
  {
    "id": "IDENTIFICADOR_DEL_EVENTO",
    "type": "sales.order-confirmed.v1",
    "occurredAt": "FECHA_ISO_GENERADA",
    "data": { "orderId": "IDENTIFICADOR_DEL_PEDIDO" }
  }
]

Repite la confirmación: espera 422 y ORDER_NOT_DRAFT. Consulta otra vez las confirmaciones: debe seguir existiendo una sola. La versión del pedido no debe aumentar.

Para probar cancelación, crea otro borrador y ejecuta:

curl -i -X POST http://127.0.0.1:3000/orders/ID/cancel

Esperas 200 y CANCELLED. Intentar confirmarlo devuelve 422. Un borrador creado con lines vacío se guarda, pero confirmarlo devuelve ORDER_EMPTY.

Detén el servidor con Ctrl+C y ejecuta de nuevo npm start desde la misma carpeta. Consulta el primer UUID: estado, versión y confirmación deben continuar disponibles. No borres la base para realizar esta comprobación.

En PowerShell también puedes crear el pedido y capturar el identificador sin copiarlo manualmente:

$createdOrder = Invoke-RestMethod -Method Post `
  -Uri "http://127.0.0.1:3000/orders" `
  -ContentType "application/json" `
  -Body '{"lines":[{"productId":"book-1","quantity":2}]}'

Invoke-RestMethod -Method Post `
  -Uri "http://127.0.0.1:3000/orders/$($createdOrder.id)/confirm"

Invoke-RestMethod `
  -Uri "http://127.0.0.1:3000/orders/$($createdOrder.id)/confirmations"

No se ofrecen salidas simuladas como resultados reales: los estados anteriores son expectativas que tu ejecución debe confirmar.

11. Documentar y trabajar con IA sobre el proyecto

Conserva este artículo o sus instrucciones en README.md, y registra junto al código las decisiones de alcance. Como mínimo documenta las reglas de la sección 1, comandos, puerto, ubicación de la base, contrato HTTP, política de confirmación repetida y limitaciones.

Una tarea inicial para un agente puede ser:

Inspecciona este proyecto sin modificarlo.
Relaciona cada regla de pedidos con su método y su prueba.
Confirma la dirección de imports y enumera las decisiones
del README que no estén representadas en código.
No agregues infraestructura ni nuevas reglas.
Devuelve evidencia con rutas y preguntas pendientes.

Para una revisión posterior:

Revisa la confirmación de pedidos.
Comprueba que una escritura con versión obsoleta falla,
que el evento se guarda en la misma transacción y que una
confirmación rechazada no añade registros.
Examina también manejo de errores y límites HTTP.
No declares resultados de pruebas que no hayas ejecutado.
No modifiques dependencias ni publiques la aplicación.

Y para comprobar el trabajo de forma reproducible:

Lee los scripts existentes y ejecuta, si el entorno está
autorizado y preparado, typecheck, test y build.
Informa el resultado real de cada comando y sus limitaciones.
Si falla algo, propón la corrección mínima antes de ampliar
el alcance. Conserva los cambios ajenos.

El responsable humano debe validar que las reglas siguen representando el negocio. Que el código pase sus pruebas no demuestra que las decisiones sean correctas para otra tienda.

12. Qué reutilizar y qué cambiar en otros escenarios

En una plataforma educativa, el patrón de transición puede servir para una inscripción. Sin embargo, «completado» podría depender de actividades aprobadas. No copies la regla «contiene una línea» ni la política de cancelación del pedido: define evidencias educativas y límites propios.

En logística, confirmar una preparación podría depender de una reserva de inventario. Nuestro proyecto no realiza esa reserva y una llamada HTTP externa no quedaría incluida en COMMIT. Antes de añadirla hay que acordar coordinación, fallos y responsabilidades.

Para una agenda personal sin reglas relevantes, un CRUD directo puede ser suficiente. La separación de este proyecto se justifica por transiciones, rechazos, consistencia y pruebas; repetir su estructura en una tabla auxiliar sin comportamiento puede añadir trabajo sin beneficio.

13. Ejercicios de revisión, con soluciones

Las funcionalidades esenciales ya están implementadas. Estos ejercicios ayudan a comprobar que comprendes sus límites, sin dejar pasos obligatorios pendientes.

  1. Dos operadores cargan el mismo borrador. Uno confirma y el otro cancela. ¿Qué dato evita que el último sobrescriba silenciosamente al primero?
  2. El handler empieza a enviar un correo antes de COMMIT. Después falla la transacción. ¿Qué inconsistencia aparece?
  3. Un cliente envía quantity igual a cero. Otro proceso interno llama directamente a Order.create con esa cantidad. ¿Dónde debe rechazarse cada entrada?

Soluciones razonadas

1. La versión esperada en el WHERE de UPDATE. Una copia obsoleta no afecta filas y genera ORDER_VERSION_CONFLICT. El segundo operador debe volver a consultar y decidir con el nuevo estado; no se reintenta ciegamente una operación del negocio.

2. El destinatario podría recibir una confirmación que la base terminó revirtiendo. Por eso el handler actual solo transforma y registra datos locales. Un envío real exige otro diseño de entrega; no basta con mover un await dentro de la transacción.

3. HTTP devuelve 400 al fallar Zod. La llamada interna también debe fallar mediante Quantity. El esquema de transporte protege el contrato de entrada y el dominio mantiene su regla aunque no exista HTTP.

Cerrar la colección construyendo con criterio

El recorrido produjo una aplicación pequeña con un comportamiento completo: entrada validada, agregado, casos de uso, repositorio, SQLite, evento procesado, consulta y pruebas. También dejó decisiones visibles: un contexto implementado, un único proceso, transacción local y ninguna dependencia distribuida inventada.

Su límite productivo es explícito: faltan identidad, autorización, operación y controles apropiados a un despliegue real. Su propósito local está cubierto por los archivos y operaciones del tutorial, y su verificación depende de ejecutar los comandos y revisar sus resultados.

DDD aportó el lenguaje y las reglas; la ingeniería conectó contratos, persistencia y pruebas; la IA puede ayudar a inspeccionar y revisar esas decisiones. El criterio profesional consiste en comprobar que las tres partes siguen describiendo el mismo negocio.

Cargando tus opciones de aprendizaje…

Cargando conversación…

Sigue aprendiendo