Skip to main content

Installation

Only the core library is needed:
npm install @woltz/rich-domain zod
You can use any database driver:
  • PostgreSQL: pg, postgres
  • MySQL: mysql2
  • SQLite: better-sqlite3
  • Or any other driver

Define Your Domain

Create an aggregate with a child entity. Only the aggregate root (Order) is loaded and saved through the repository — child entities (OrderItem) are tracked automatically.
import { z } from "zod";
import { Aggregate, Entity, EntityValidation, Id } from "@woltz/rich-domain";

// Child entity (lives inside the Order aggregate)
const orderItemSchema = z.object({
  id: z.custom<Id>((val) => val instanceof Id),
  productId: z.string().min(1),
  quantity: z.number().int().positive(),
  unitPrice: z.number().positive(),
});

type OrderItemProps = z.infer<typeof orderItemSchema>;

class OrderItem extends Entity<OrderItemProps> {
  protected static validation: EntityValidation<OrderItemProps> = {
    schema: orderItemSchema,
    config: { onCreate: true, onUpdate: true, throwOnError: true },
  };

  get productId() {
    return this.props.productId;
  }

  get quantity() {
    return this.props.quantity;
  }

  get unitPrice() {
    return this.props.unitPrice;
  }

  get subtotal() {
    return this.props.quantity * this.props.unitPrice;
  }

  updateQuantity(quantity: number) {
    this.props.quantity = quantity;
  }
}

// Aggregate root
const orderSchema = z.object({
  id: z.custom<Id>((val) => val instanceof Id),
  customerId: z.string().min(1),
  status: z.enum(["draft", "confirmed", "shipped"]),
  items: z.array(z.custom<OrderItem>((val) => val instanceof OrderItem)),
  createdAt: z.date(),
});

type OrderProps = z.infer<typeof orderSchema>;

class Order extends Aggregate<OrderProps> {
  protected static validation: EntityValidation<OrderProps> = {
    schema: orderSchema,
    config: { onCreate: true, onUpdate: true, throwOnError: true },
  };

  get customerId() {
    return this.props.customerId;
  }

  get status() {
    return this.props.status;
  }

  get items() {
    return this.props.items;
  }

  get total() {
    return this.props.items.reduce((sum, item) => sum + item.subtotal, 0);
  }

  getTypedChanges() {
    interface Entities {
      Order: Order;
      OrderItem: OrderItem;
    }
    return this.getChanges<Entities>();
  }

  addItem(productId: string, quantity: number, unitPrice: number) {
    this.props.items.push(new OrderItem({ productId, quantity, unitPrice }));
  }

  removeItem(itemId: Id) {
    this.props.items = this.props.items.filter(
      (item) => item.id.value !== itemId.value
    );
  }

  confirm() {
    this.props.status = "confirmed";
  }
}

Implement Repository

Use getChanges() and toBatchOperations() to persist only what changed — including child entities — in the correct order for foreign keys.

PostgreSQL with pg

import { Pool, PoolClient } from "pg";
import { Criteria, PaginatedResult } from "@woltz/rich-domain";

const pool = new Pool({
  host: "localhost",
  port: 5432,
  database: "mydb",
  user: "user",
  password: "password",
});

// Map domain field names to SQL column names
const columns: Record<string, Record<string, string>> = {
  Order: { customerId: "customer_id", status: "status" },
  OrderItem: {
    productId: "product_id",
    quantity: "quantity",
    unitPrice: "unit_price",
  },
};

class PostgresOrderRepository {
  async save(order: Order): Promise<void> {
    const changes = order.getTypedChanges();

    if (changes.isEmpty() && !order.isNew()) return;

    const client = await pool.connect();

    try {
      await client.query("BEGIN");

      // Insert the aggregate root on first save
      if (order.isNew()) {
        await client.query(
          `INSERT INTO orders (id, customer_id, status, created_at)
           VALUES ($1, $2, $3, $4)`,
          [order.id.value, order.customerId, order.status, order.createdAt]
        );
      }

      const batch = changes.toBatchOperations();

      // 1. Deletes (children first — leaf → root)
      for (const del of batch.deletes) {
        await client.query(
          `DELETE FROM order_items WHERE id = ANY($1::uuid[])`,
          [del.ids]
        );
      }

      // 2. Creates (parent before children — root → leaf)
      for (const create of batch.creates) {
        for (const item of create.items) {
          const child = item.data as OrderItem;
          await client.query(
            `INSERT INTO order_items (id, order_id, product_id, quantity, unit_price)
             VALUES ($1, $2, $3, $4, $5)`,
            [
              child.id.value,
              item.parentId,
              child.productId,
              child.quantity,
              child.unitPrice,
            ]
          );
        }
      }

      // 3. Updates (only changed fields)
      for (const update of batch.updates) {
        const table = update.entity === "Order" ? "orders" : "order_items";
        const columnMap = columns[update.entity];

        for (const item of update.items) {
          const fields = Object.keys(item.changedFields);
          const setClauses = fields
            .map((field, idx) => `${columnMap[field]} = $${idx + 2}`)
            .join(", ");
          const values = fields.map((field) => item.changedFields[field]);

          await client.query(
            `UPDATE ${table} SET ${setClauses} WHERE id = $1`,
            [item.id, ...values]
          );
        }
      }

      await client.query("COMMIT");

      // Reset change tracking after successful save
      if (order.isNew()) {
        order.markAsPersisted();
      } else {
        order.markAsClean();
      }
    } catch (error) {
      await client.query("ROLLBACK");
      throw error;
    } finally {
      client.release();
    }
  }

  async findById(id: string): Promise<Order | null> {
    const orderResult = await pool.query(
      "SELECT * FROM orders WHERE id = $1",
      [id]
    );

    if (orderResult.rows.length === 0) return null;

    const row = orderResult.rows[0];

    const itemsResult = await pool.query(
      "SELECT * FROM order_items WHERE order_id = $1",
      [id]
    );

    const items = itemsResult.rows.map(
      (item) =>
        new OrderItem({
          id: Id.from(item.id),
          productId: item.product_id,
          quantity: item.quantity,
          unitPrice: parseFloat(item.unit_price),
        })
    );

    return new Order({
      id: Id.from(row.id),
      customerId: row.customer_id,
      status: row.status,
      items,
      createdAt: row.created_at,
    });
  }

  async find(criteria: Criteria<Order>): Promise<PaginatedResult<Order>> {
    const filters = criteria.getFilters();
    const orders = criteria.getOrders();
    const pagination = criteria.getPagination();

    const whereClauses: string[] = [];
    const values: unknown[] = [];
    let paramIndex = 1;

    for (const filter of filters) {
      switch (filter.operator) {
        case "equals":
          whereClauses.push(`${filter.field} = $${paramIndex}`);
          values.push(filter.value);
          paramIndex++;
          break;
        case "contains":
          whereClauses.push(`${filter.field} ILIKE $${paramIndex}`);
          values.push(`%${filter.value}%`);
          paramIndex++;
          break;
        case "greaterThan":
          whereClauses.push(`${filter.field} > $${paramIndex}`);
          values.push(filter.value);
          paramIndex++;
          break;
      }
    }

    const whereClause =
      whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";

    const orderClauses = orders.map(
      (o) => `${o.field} ${o.direction.toUpperCase()}`
    );
    const orderClause =
      orderClauses.length > 0 ? `ORDER BY ${orderClauses.join(", ")}` : "";

    const countResult = await pool.query(
      `SELECT COUNT(*) FROM orders ${whereClause}`,
      values
    );
    const total = parseInt(countResult.rows[0].count);

    const offset = (pagination.page - 1) * pagination.limit;
    const dataResult = await pool.query(
      `SELECT * FROM orders ${whereClause} ${orderClause} LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
      [...values, pagination.limit, offset]
    );

    const data = await Promise.all(
      dataResult.rows.map(async (row) => {
        const itemsResult = await pool.query(
          "SELECT * FROM order_items WHERE order_id = $1",
          [row.id]
        );

        const items = itemsResult.rows.map(
          (item) =>
            new OrderItem({
              id: Id.from(item.id),
              productId: item.product_id,
              quantity: item.quantity,
              unitPrice: parseFloat(item.unit_price),
            })
        );

        return new Order({
          id: Id.from(row.id),
          customerId: row.customer_id,
          status: row.status,
          items,
          createdAt: row.created_at,
        });
      })
    );

    return {
      data,
      meta: {
        page: pagination.page,
        limit: pagination.limit,
        total,
        totalPages: Math.ceil(total / pagination.limit),
        hasNext: offset + pagination.limit < total,
        hasPrevious: pagination.page > 1,
      },
    };
  }

  async delete(id: string): Promise<void> {
    await pool.query("DELETE FROM orders WHERE id = $1", [id]);
  }
}
toBatchOperations() groups changes by entity and orders them to respect foreign keys: deletes run leaf → root, creates run root → leaf, updates run in any order.

Create Database Tables

Create your schema manually:
CREATE TABLE orders (
  id UUID PRIMARY KEY,
  customer_id VARCHAR(255) NOT NULL,
  status VARCHAR(50) NOT NULL,
  created_at TIMESTAMP DEFAULT NOW()
);

CREATE TABLE order_items (
  id UUID PRIMARY KEY,
  order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
  product_id VARCHAR(255) NOT NULL,
  quantity INTEGER NOT NULL,
  unit_price DECIMAL(10, 2) NOT NULL
);

CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_orders_customer_id ON orders(customer_id);
CREATE INDEX idx_order_items_order_id ON order_items(order_id);

Use It

const repository = new PostgresOrderRepository();

// Create order with items
const order = new Order({
  customerId: "cust-123",
  status: "draft",
  items: [],
  createdAt: new Date(),
});

order.addItem("prod-keyboard", 2, 149.99);
order.addItem("prod-mouse", 1, 79.99);

console.log(order.isNew()); // true
console.log(order.total); // 379.97

// First save — inserts order + items
await repository.save(order);
console.log(order.isNew()); // false (after markAsPersisted)

// Modify aggregate and children
order.confirm();
order.items[0].updateQuantity(3);
order.addItem("prod-pad", 1, 29.99);
order.removeItem(order.items[1].id);

// Inspect changes before saving
const changes = order.getTypedChanges();
console.log(changes.hasUpdates()); // true — status and item quantity changed
console.log(changes.hasCreates()); // true — new pad item
console.log(changes.hasDeletes()); // true — mouse item removed

const itemChanges = changes.of("OrderItem");
console.log(itemChanges.creates.length); // 1
console.log(itemChanges.updates[0]?.changed); // { quantity: 3 }
console.log(itemChanges.deleteIds); // ["..."]

// Save only what changed
await repository.save(order);

// Query with Criteria
const criteria = Criteria.create<Order>()
  .where("status", "equals", "confirmed")
  .orderBy("createdAt", "desc")
  .paginate(1, 10);

const results = await repository.find(criteria);
console.log(results.data);
console.log(results.meta);

// Load by ID (includes child items)
const found = await repository.findById(order.id.value);
console.log(found?.items.length);

Transactions

Wrap multiple operations in a transaction with your database driver:
class PostgresOrderRepository {
  async withTransaction<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {
    const client = await pool.connect();
    try {
      await client.query("BEGIN");
      const result = await fn(client);
      await client.query("COMMIT");
      return result;
    } catch (error) {
      await client.query("ROLLBACK");
      throw error;
    } finally {
      client.release();
    }
  }
}

// Usage — confirm order atomically
await repository.withTransaction(async () => {
  const order = await repository.findById(orderId);
  if (!order) throw new Error("Order not found");

  order.confirm();
  order.items.forEach((item) => {
    if (item.quantity > 5) item.updateQuantity(item.quantity - 1);
  });

  await repository.save(order);
});

Validation

Validation works the same for aggregates and child entities:
// Invalid child on creation — throws ValidationError
order.addItem("prod-x", 0, 10);
// ❌ ValidationError: quantity must be positive

// Invalid on update — throws ValidationError
order.items[0].updateQuantity(-1);
// ❌ ValidationError: quantity must be positive

Next Steps

Repository Pattern

Learn advanced repository patterns

Change Tracking

Understand how changes are tracked

Criteria API

Type-safe query building

Entities & Aggregates

Core DDD concepts