Installation
Only the core library is needed:npm install @woltz/rich-domain zod
- 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
UsegetChanges() 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