# CLI Source: https://woltz.mintlify.app/CLI Initialize projects and generate domain entities from Prisma schemas ## Overview `@woltz/rich-domain-cli` provides CLI tools for scaffolding DDD projects and generating domain entities from Prisma schemas. ```bash theme={null} npm install -D @woltz/rich-domain-cli # Or run directly with npx npx rich-domain ``` Initialize complete projects from templates Generate domain from existing schemas Auto-detects Aggregates vs Entities Schemas, entities, repositories, mappers *** ## Requirements * Node.js >= 20 * Prisma schema file (for `generate` command) * `@woltz/rich-domain` (required) * `@woltz/rich-domain-prisma` (optional, for repositories/mappers) * Validation library (optional, for runtime validation) *** ## Commands The CLI provides three main commands: | Command | Description | | ---------- | -------------------------------------------------- | | `init` | Initialize a new project from a template | | `generate` | Generate domain entities from Prisma schema | | `add` | Manually create entity, aggregate, or value object | *** ## init Initialize a new DDD project from a template with all infrastructure pre-configured. ```bash theme={null} npx rich-domain init [directory] [options] ``` ### Options | Option | Alias | Default | Description | | ------------------------ | ----- | ------- | ----------------------------------- | | `--template ` | `-t` | Prompt | Template to use | | `--package-manager ` | `-p` | Prompt | `npm`, `pnpm`, `yarn`, or `bun` | | `--force` | `-f` | `false` | Skip prompts and overwrite existing | ### Examples ```bash theme={null} # Interactive mode npx rich-domain init my-app # Specify template and package manager npx rich-domain init my-app --template fullstack-prisma -p pnpm # Initialize in current directory npx rich-domain init . --template fullstack-drizzle # Skip all prompts npx rich-domain init my-app -t fullstack-typeorm -p npm -f ``` *** ## Templates ### Available Templates | Template | Stack | Description | | ------------------- | ------------------------------------ | -------------------------------------- | | `fullstack-prisma` | Fastify + Prisma + BullMQ + Zod | Prisma v7 with driver adapter (pg) | | `fullstack-drizzle` | Fastify + Drizzle ORM + BullMQ + Zod | Drizzle with node-postgres | | `fullstack-typeorm` | Fastify + TypeORM + BullMQ + Zod | TypeORM with decorators and DataSource | All templates share the same DDD architecture and generate the same project structure. They differ only in the ORM layer. ### Generated Project Structure ``` my-app/ ├── prisma.config.ts # ORM config (Prisma) / drizzle.config.ts (Drizzle) ├── docker-compose.yml # PostgreSQL + Redis ├── .env.example ├── tsconfig.json └── src/ ├── env.ts # Zod-validated environment variables ├── server.ts # Fastify app entry point ├── constants.ts # Queue name constants ├── domain/ │ ├── entities/ │ │ └── user.aggregate.ts │ ├── repositories/ │ │ └── user.repository.ts │ ├── events/ │ │ └── user-created.event.ts │ └── value-objects/ │ └── email.ts ├── application/ │ ├── service/ │ │ └── user.service.ts │ └── processor/ │ ├── events.processor.ts # Observe-only handlers (logging, emails) │ └── action.processor.ts # Mutation handlers ├── infra/ │ ├── database/ │ │ ├── / # Schema/entity files │ │ ├── mappers/ # Domain ↔ persistence mappers │ │ ├── repositories/ # Concrete repository implementations │ │ ├── schemas/ # Persistence type definitions │ │ └── seed/ │ ├── queue/ │ │ ├── connection.ts # IORedis connection │ │ ├── event-bus.ts # BullMQ IDomainEventBus implementation │ │ ├── event-worker.ts # BullMQ domain event worker │ │ └── queue-publisher.ts │ └── di/ │ ├── container.ts # DI container │ └── fastify-plugin.ts # Fastify DI decorator └── presentation/ ├── controllers/ │ └── user.controller.ts └── dto/ └── user/ └── user.dto.ts ``` ### Scripts All templates include the same base scripts: | Script | Description | | ------------- | ------------------------------- | | `dev` | Start dev server with tsx watch | | `build` | Compile TypeScript | | `start` | Start production server | | `lint` | Type-check without emitting | | `db:generate` | Generate ORM client/migrations | | `db:migrate` | Run migrations | | `db:push` | Push schema to database | | `db:studio` | Open ORM studio UI | | `db:seed` | Run seed script | | `docker:up` | Start PostgreSQL + Redis | | `docker:down` | Stop Docker services | TypeORM does not include `db:generate`, `db:migrate`, `db:push`, or `db:studio` — schema synchronization is handled by `synchronize: true` in development mode. ### fullstack-prisma Uses **Prisma v7** with the `@prisma/adapter-pg` driver adapter (required in v7) and `prisma.config.ts` for schema configuration. **Key dependencies:** `@prisma/client`, `@prisma/adapter-pg`, `pg`, `@woltz/rich-domain-prisma` ```typescript theme={null} // src/infra/database/prisma.ts import { PrismaClient } from "@prisma/client"; import { PrismaPg } from "@prisma/adapter-pg"; const adapter = new PrismaPg({ connectionString: env.DATABASE_URL }); export const prisma = new PrismaClient({ adapter }); ``` ```typescript theme={null} // prisma.config.ts import { defineConfig } from "prisma/config"; export default defineConfig({ schema: path.join("src", "infra", "database", "prisma", "schema.prisma"), datasource: { url: env.DATABASE_URL, }, migrations: { path: path.join("src", "infra", "database", "prisma", "migrations"), seed: path.join("src", "infra", "database", "seed", "seed.ts"), }, }); ``` ### fullstack-drizzle Uses **Drizzle ORM** with `drizzle-orm/node-postgres` and a lazy-initialized connection pool. **Key dependencies:** `drizzle-orm`, `pg`, `@woltz/rich-domain-drizzle` ```typescript theme={null} // src/infra/database/db.ts export async function initializeDatabase() { pool = new Pool({ connectionString: env.DATABASE_URL }); db = drizzle(pool, { schema }); } ``` ```typescript theme={null} // drizzle.config.ts export default defineConfig({ schema: "./src/infra/database/drizzle/schema.ts", out: "./src/infra/database/drizzle/migrations", dialect: "postgresql", }); ``` ### fullstack-typeorm Uses **TypeORM** with `DataSource`, decorator-based entities, and `synchronize: true` in development. **Key dependencies:** `typeorm`, `pg`, `reflect-metadata`, `@woltz/rich-domain-typeorm` ```typescript theme={null} // src/infra/database/db.ts export const AppDataSource = new DataSource({ type: "postgres", url: env.DATABASE_URL, synchronize: env.NODE_ENV === "development", entities: [UserEntity], }); ``` The TypeORM template enables `synchronize: true` in development mode. Disable this and use migrations in production. ### Getting Started After running `init`: ```bash theme={null} cd my-app pnpm install cp .env.example .env pnpm docker:up pnpm db:push # or db:migrate for Prisma/Drizzle pnpm dev ``` ``` API: http://localhost:3000 Swagger: http://localhost:3000/doc ``` *** ## generate Generate domain entities, repositories, and mappers from your Prisma schema. ```bash theme={null} npx rich-domain generate [options] ``` ### Options | Option | Alias | Default | Description | | --------------------- | ----- | ----------- | -------------------------------------- | | `--schema ` | `-s` | Auto-detect | Path to Prisma schema file | | `--output ` | `-o` | `src` | Output directory | | `--validation ` | `-v` | Auto-detect | `zod`, `valibot`, `arktype`, or `none` | | `--models ` | `-m` | All models | Comma-separated list of models | | `--dry-run` | - | `false` | Preview without writing files | | `--force` | `-f` | `false` | Skip confirmation prompts | ### Examples ```bash theme={null} # Basic usage (auto-detect schema and validation) npx rich-domain generate # Specify schema path npx rich-domain generate --schema ./prisma/schema.prisma # Generate to custom directory with Zod npx rich-domain generate --output ./src/domain --validation zod # Generate only specific models npx rich-domain generate --models User,Post,Comment # Preview without writing files npx rich-domain generate --dry-run ``` ### Package Detection The CLI automatically detects installed packages and adjusts generation accordingly: ```bash theme={null} 🔍 Rich Domain Generator Reading schema from prisma/schema.prisma Detected packages: • @woltz/rich-domain-prisma: ✓ installed • Validation library: zod ``` | Package | Effect | | --------------------------- | ------------------------------------ | | `@woltz/rich-domain-prisma` | Generates repositories and mappers | | `zod` | Uses Zod for schema validation | | `valibot` | Uses Valibot for schema validation | | `arktype` | Uses ArkType for schema validation | | None | Generates TypeScript interfaces only | Repository and mapper files are only generated when `@woltz/rich-domain-prisma` is installed. ### Generated Structure For a Prisma schema with `User`, `Post`, and `Comment` models: ``` src/ ├── shared/ │ └── enums.ts # All Prisma enums ├── user/ │ ├── index.ts # Barrel export │ ├── user.schema.ts # Zod schema │ ├── user.aggregate.ts # Aggregate class │ ├── user.repository.ts # Repository* │ ├── user-to-domain.mapper.ts # Domain mapper* │ └── user-to-persistence.mapper.ts # Persistence mapper* ├── post/ │ ├── index.ts │ ├── post.schema.ts │ ├── post.aggregate.ts # Aggregate (has children) │ └── ... └── comment/ ├── index.ts ├── comment.schema.ts └── comment.entity.ts # Entity (owned by Post) * Only generated when @woltz/rich-domain-prisma is installed ``` *** ## Classification Logic The CLI classifies models as **Aggregates** or **Entities** based on their relationships. ### Classification Rules | Condition | Classification | Reason | | ---------------------------- | -------------- | -------------------- | | Referenced by others via FK | **Aggregate** | It's a parent/root | | Has list relations (1:N) | **Aggregate** | It owns children | | Has N:N relations | **Aggregate** | Both sides are roots | | Only has FKs, not referenced | **Entity** | It's a child/owned | | No relations | **Aggregate** | Standalone root | *** ## Validation Libraries ### Zod (Default) ```typescript theme={null} import { z } from "zod"; export const userSchema = z.object({ id: z.custom((v) => v instanceof Id), email: z.string().email(), name: z.string(), age: z.number().int().min(0), isActive: z.boolean(), createdAt: z.date(), }); ``` ### Valibot ```typescript theme={null} import * as v from "valibot"; export const userSchema = v.object({ id: v.custom((v) => v instanceof Id), email: v.pipe(v.string(), v.email()), name: v.string(), age: v.pipe(v.number(), v.integer(), v.minValue(0)), isActive: v.boolean(), createdAt: v.date(), }); ``` ### ArkType ```typescript theme={null} import { type } from "arktype"; export const userSchema = type({ id: "instanceof Id", email: "string.email", name: "string", age: "integer >= 0", isActive: "boolean", createdAt: "Date", }); ``` ### None (Interfaces Only) ```typescript theme={null} import { Id } from "@woltz/rich-domain"; export interface UserProps { id: Id; email: string; name: string; age: number; isActive: boolean; createdAt: Date; } ``` *** ## Best Practices ### After Generation 1. **Review classifications** - Adjust Aggregate/Entity based on actual domain boundaries 2. **Add business logic** - The generated code is a starting point 3. **Add validation rules** - Customize schemas with business constraints 4. **Configure hooks** - Add lifecycle hooks as needed ### When to Re-generate * After changing Prisma schema structure * After adding new models * Use `--models` flag to regenerate specific models only Use `--dry-run` first to preview changes before overwriting files. ### Manual Adjustments Some scenarios require manual adjustment: | Scenario | Adjustment Needed | | ----------------------------- | ----------------------------------- | | Lookup tables (Tag, Category) | May want to be Value Objects | | Self-referential relations | Review aggregate boundaries | | Complex N:N | Decide which side owns the relation | *** ## add Manually create an entity, aggregate, or value object without requiring a Prisma schema. ```bash theme={null} npx rich-domain add [...props] [options] ``` ### Options | Option | Alias | Default | Description | | ----------------- | ----- | ----------- | ------------------------------------------------ | | `--aggregate` | `-a` | `true` | Create an Aggregate (default) | | `--entity` | `-e` | `false` | Create an Entity | | `--value-object` | `-v` | `false` | Create a Value Object | | `--with-repo` | - | `false` | Generate repository interface and implementation | | `--with-mapper` | - | `false` | Generate ToDomain and ToPersistence mappers | | `--with-all` | - | `false` | Generate repository and mappers | | `--output ` | `-o` | Auto-detect | Output directory | | `--force` | `-f` | `false` | Skip prompts and overwrite | ### Examples ```bash theme={null} # Create a simple aggregate npx rich-domain add User # Create with properties npx rich-domain add User name:string email:string # Create with optional and enum properties npx rich-domain add User name:string role:(USER,ADMIN) age:number? # Create a Value Object (single primitive property) npx rich-domain add Email -v email:string # Create an Entity npx rich-domain add OrderItem -e quantity:number price:number # Create with relations npx rich-domain add Product name:string author:User tags:Tag[] # Create with repository and mappers npx rich-domain add Order --with-all ``` ### Props Syntax The props follow a `name:type` format with support for various modifiers: | Syntax | Description | Example | | --------------- | --------------------- | -------------------------- | | `name:string` | Required string | `email:string` | | `name:number` | Required number | `age:number` | | `name:boolean` | Required boolean | `isActive:boolean` | | `name:date` | Required date | `createdAt:date` | | `name:type?` | Optional (nullable) | `bio:string?` | | `name:type[]` | Array | `tags:string[]` | | `name:(A,B,C)` | Inline enum | `status:(ACTIVE,INACTIVE)` | | `name:Entity` | Relation (PascalCase) | `author:User` | | `name:Entity[]` | Array relation | `posts:Post[]` | ### Props Examples ```bash theme={null} # Basic types npx rich-domain add User \ name:string \ email:string \ age:number \ isAdmin:boolean \ createdAt:date # Optional fields npx rich-domain add Profile \ bio:string? \ website:string? \ avatar:string? # Arrays npx rich-domain add Article \ title:string \ tags:string[] \ categories:string[] # Enums npx rich-domain add Order \ status:(PENDING,PROCESSING,SHIPPED,DELIVERED) \ priority:(LOW,MEDIUM,HIGH) # Relations npx rich-domain add Post \ title:string \ content:string \ author:User \ comments:Comment[] # Combined npx rich-domain add Product \ name:string \ price:number \ description:string? \ status:(DRAFT,PUBLISHED,ARCHIVED) \ tags:string[] \ category:Category \ reviews:Review[] ``` ### Auto-Detection The `add` command automatically detects: 1. **Validation Library**: Checks `package.json` for `zod`, `valibot`, or `arktype` 2. **Prisma Adapter**: Detects `@woltz/rich-domain-prisma` for repository/mapper generation 3. **Output Directory**: Looks for existing `src/domain` or `src` directories If `@woltz/rich-domain-prisma` is not installed, generic implementations are generated that you'll need to customize for your database. # Change Tracking Source: https://woltz.mintlify.app/core/change-tracking Track changes across entities and aggregate object graphs ## What is Change Tracking? Change tracking is implemented by `BaseEntity`, so it is available to both `Entity` and `Aggregate`. JavaScript Proxies observe property assignments and collection mutations, while snapshots are used to calculate create, update, and delete operations for nested entities. In normal DDD usage, call `getChanges()` on the aggregate root so the returned operations represent a single consistency and persistence boundary. ```typescript theme={null} const order = new Order({ id: Id.from("order-123"), status: "draft", items: [ new OrderItem({ id: Id.from("item-1"), quantity: 2, unitPrice: 29.99 }), ], }); // Make changes naturally order.confirm(); order.items[0].updateQuantity(5); order.addItem("prod-456", 1, 49.99); // Get all changes with a single call const changes = order.getChanges(); console.log(changes.hasUpdates()); // true - order status and item quantity changed console.log(changes.hasCreates()); // true - new item added ``` Persist detected updates instead of rewriting every child Changes tracked automatically - no manual code needed Creates and deletes ordered by object-graph depth Group changes by entity type for optimized database operations Construction establishes the initial baseline. `getChanges()` reports mutations made after construction or after the most recent `markAsClean()`/`markAsPersisted()` call. It does not emit a create operation for the root object itself; repositories normally use `isNew()` for that decision. ## Basic Usage ### Getting Changes ```typescript theme={null} const changes = order.getChanges(); // Check what changed changes.hasCreates(); // Any new entities? changes.hasUpdates(); // Any modified entities? changes.hasDeletes(); // Any deleted entities? changes.hasChanges(); // Any changes at all? changes.isEmpty(); // No changes? // Get count changes.count; // Total number of operations ``` ### Accessing Operations ```typescript theme={null} // Get all creates (ordered root → leaf) const creates = changes.creates(); // CreateOperation[] // Get all updates (no specific order) const updates = changes.updates(); // UpdateOperation[] // Get all deletes (ordered leaf → root) const deletes = changes.deletes(); // DeleteOperation[] // Iterate over all operations for (const op of changes.operations()) { console.log(op.type, op.entity, op.depth); } ``` ### Filtering by Entity Get changes for a specific entity type: ```typescript theme={null} const itemChanges = changes.of("OrderItem"); // Access entities directly itemChanges.creates; // OrderItem[] - new items itemChanges.deletes; // OrderItem[] - deleted items itemChanges.updates; // Array<{ entity: OrderItem, changed: {...} }> // Get IDs itemChanges.createIds; // string[] itemChanges.updateIds; // string[] itemChanges.deleteIds; // string[] // Check for changes itemChanges.hasCreates(); itemChanges.hasUpdates(); itemChanges.hasDeletes(); // Count itemChanges.count; ``` ### Filtering by Relation Use `forRelation()` to select operations associated with a property in the parent object: ```typescript theme={null} const itemRelationChanges = changes.forRelation("items"); console.log(itemRelationChanges.hasChanges()); console.log(itemRelationChanges.getAffectedRelations()); // ["items"] ``` ### Excluding Entities Use `without()` when you need the inverse of `of()` — all changes **except** one or more entity types. It returns a **new** `AggregateChanges` instance; the original is unchanged. ```typescript theme={null} const profileChanges = changes.of("Profile"); if (profileChanges.hasUpdates()) { await this.context.factoryProfile.update({ where: { factoryId: factory.id.value }, data: this.registry.mapFields("Profile", profileChanges.updates[0].changed), }); } const remaining = changes.without("Profile"); if (!remaining.isEmpty()) { await super.onUpdate(remaining, factory); } ``` Pass a single entity name or an array to exclude multiple types: ```typescript theme={null} changes.without("Profile"); changes.without(["Profile", "Tag"]); ``` This is useful in custom `onUpdate` implementations when one entity needs manual persistence (nested writes, custom PK, etc.) and the rest should go through `PrismaBatchExecutor`. ### Resetting the Baseline The component that successfully persists the object graph should reset its tracking baseline: ```typescript theme={null} await persistChanges(order.getChanges()); order.markAsClean(); // Now tracking starts fresh const changes = order.getChanges(); console.log(changes.isEmpty()); // true ``` `markAsClean()` recursively clears changes in nested entities without changing whether their IDs are new. `PrismaRepository.save()` already calls `markAsPersisted()` after its mapper completes successfully. Do not add a second cleanup call when using that repository. For a custom repository or direct batch executor, reset the baseline only after persistence succeeds. ### After First Save When implementing a custom repository, call `markAsPersisted()` after the first successful INSERT. It clears tracking and marks the root and nested IDs as not new: ```typescript theme={null} await insertAggregate(order); order.markAsPersisted(); console.log(order.isNew()); // false console.log(order.getChanges().isEmpty()); // true order.updateStatus("confirmed"); await updateAggregate(order); order.markAsClean(); ``` `markAsPersisted()` changes ID state; it does not perform an INSERT by itself. The repository or mapper decides how `isNew()` affects persistence. ## Batch Operations Use `toBatchOperations()` to group changes by entity, relation, and parent metadata: `toBatchOperations()` is a data transformation only. It does not execute a transaction, map domain fields to database columns, or decide whether a relation is owned or referenced. A persistence adapter must handle those concerns. ```typescript theme={null} const batch = changes.toBatchOperations(); // Deletes are grouped and ordered leaf → root for (const del of batch.deletes) { console.log(del.entity, del.ids, { parentId: del.parentId, parentEntity: del.parentEntity, relationField: del.relationField, }); } // Creates are grouped and ordered parent → child for (const create of batch.creates) { for (const item of create.items) { console.log(create.entity, item.data, { parentId: item.parentId, parentEntity: item.parentEntity, relationField: item.relationField, }); } } // Updates are grouped by entity and are not depth-sorted for (const update of batch.updates) { for (const item of update.items) { console.log(update.entity, item.id, item.changedFields); } } ``` The ordering reflects the tracked object graph: * **Deletes**: Children first, then parents * **Creates**: Parents first, then children * **Updates**: Grouped by entity without depth ordering This ordering helps an adapter satisfy foreign keys, but it cannot guarantee database correctness by itself. Relationship configuration and transaction handling still belong to the adapter. ## Operation Ordering The library orders create and delete operations by their depth in the tracked object graph. Depth `0` is the object on which `getChanges()` is called. The tracker can emit an update for that root, but it does not emit a create or delete for the root itself. ### Deletes: Leaf → Root Children must be deleted before parents to avoid FK violations: ```typescript theme={null} // Given changes below an Order root: // OrderItem // └── ItemDiscount const deletes = changes.deletes(); // Returns in order: // 1. ItemDiscount (depth: 2) // 2. OrderItem (depth: 1) // The Order root itself is saved by its repository. ``` ### Creates: Root → Leaf Parents must be created before children so FKs can reference them: ```typescript theme={null} const creates = changes.creates(); // Returns in order: // 1. OrderItem (depth: 1) // 2. ItemDiscount (depth: 2) ``` ### Updates: Any Order Updates are returned in detection order and grouped by entity in batch output: ```typescript theme={null} const updates = changes.updates(); // No depth ordering guarantee ``` ## Working with Collections Collections of `Entity`/`Aggregate` instances produce item-level create, update, and delete operations. Arrays containing only primitive values are reported as an update to the property of their owning entity. ### Adding Items ```typescript theme={null} const order = new Order({ id: Id.from("order-123"), items: [], }); order.addItem("prod-1", 2, 29.99); order.addItem("prod-2", 1, 49.99); const itemChanges = order.getChanges().of("OrderItem"); console.log(itemChanges.creates.length); // 2 ``` ### Removing Items ```typescript theme={null} const order = new Order({ id: Id.from("order-123"), items: [ new OrderItem({ id: Id.from("item-1"), ... }), new OrderItem({ id: Id.from("item-2"), ... }), ], }); order.removeItem(Id.from("item-1")); const itemChanges = order.getChanges().of("OrderItem"); console.log(itemChanges.deleteIds); // ["item-1"] ``` ### Updating Items ```typescript theme={null} const order = new Order({ id: Id.from("order-123"), items: [ new OrderItem({ id: Id.from("item-1"), quantity: 2, ... }), ], }); order.items[0].updateQuantity(5); const itemChanges = order.getChanges().of("OrderItem"); console.log(itemChanges.updates[0].changed); // { quantity: 5 } ``` ### Mixed Operations ```typescript theme={null} // Create, update, and delete in the same transaction order.addItem("prod-new", 1, 39.99); // Create order.items[0].updateQuantity(10); // Update order.removeItem(Id.from("item-old")); // Delete const itemChanges = order.getChanges().of("OrderItem"); console.log(itemChanges.creates.length); // 1 console.log(itemChanges.updates.length); // 1 console.log(itemChanges.deletes.length); // 1 ``` ## Working with Single Entities (1:1) ### Setting an Entity ```typescript theme={null} const user = new User({ id: Id.from("user-1"), address: null, }); user.setAddress(new Address({ street: "123 Main St", city: "New York", })); const addressChanges = user.getChanges().of("Address"); console.log(addressChanges.hasCreates()); // true ``` ### Removing an Entity ```typescript theme={null} const user = new User({ id: Id.from("user-1"), address: new Address({ id: Id.from("addr-1"), ... }), }); user.removeAddress(); const addressChanges = user.getChanges().of("Address"); console.log(addressChanges.deleteIds); // ["addr-1"] ``` ### Updating an Entity ```typescript theme={null} const user = new User({ id: Id.from("user-1"), address: new Address({ id: Id.from("addr-1"), street: "Old St", ... }), }); user.address.changeStreet("New Street"); const addressChanges = user.getChanges().of("Address"); console.log(addressChanges.updates[0].changed); // { street: "New Street" } ``` ### Replacing an Entity ```typescript theme={null} const user = new User({ id: Id.from("user-1"), address: new Address({ id: Id.from("addr-1"), ... }), }); // Replace with completely new address (different ID) user.setAddress(new Address({ street: "789 Pine Rd", city: "Boston", })); const addressChanges = user.getChanges().of("Address"); console.log(addressChanges.hasDeletes()); // true - old address console.log(addressChanges.hasCreates()); // true - new address ``` ## Deeply Nested Changes Change tracking recursively follows nested `BaseEntity` instances and has no fixed depth limit: ```typescript theme={null} // Structure: User → Post → Comment → Like const user = new User({ id: Id.from("user-1"), posts: [ new Post({ id: Id.from("post-1"), comments: [ new Comment({ id: Id.from("comment-1"), likes: [], }), ], }), ], }); // Add a like (depth 3) user.posts[0].comments[0].addLike(new Like({ ... })); // Update comment (depth 2) user.posts[0].comments[0].changeText("Updated"); // Add new comment (depth 2) user.posts[0].addComment(new Comment({ ... })); const changes = user.getChanges(); console.log(changes.of("Comment").hasCreates()); // true console.log(changes.of("Comment").hasUpdates()); // true console.log(changes.of("Like").hasCreates()); // true ``` Circular entity references are rejected during change comparison. Across aggregate boundaries, prefer storing another aggregate's `Id` instead of an object reference. ### Cascading Deletes When a parent is removed from the tracked graph, its nested entities are also reported as delete operations: ```typescript theme={null} const user = new User({ posts: [ new Post({ id: Id.from("post-1"), comments: [ new Comment({ id: Id.from("comment-1"), likes: [...] }), new Comment({ id: Id.from("comment-2"), likes: [] }), ], }), ], }); // Remove post from the tracked object graph user.removePost(Id.from("post-1")); const changes = user.getChanges(); console.log(changes.of("Post").deleteIds); // ["post-1"] console.log(changes.of("Comment").deleteIds); // ["comment-1", "comment-2"] console.log(changes.of("Like").deleteIds); // [...] // Batch operations place deeper deletes first const batch = changes.toBatchOperations(); // Order: Like → Comment → Post (leaf to root) ``` This is a cascade in the generated change set, not a database-level `ON DELETE CASCADE`. The adapter still decides whether a relation is owned and should be deleted or is a reference that should only be disconnected. ### Cascading Creates When a parent with nested entities is added after the baseline, the parent and its nested children are reported as creates: ```typescript theme={null} user.addPost(new Post({ title: "New Post", comments: [ new Comment({ text: "First comment", likes: [ new Like({ ... }), new Like({ ... }), ], }), ], })); const changes = user.getChanges(); console.log(changes.of("Post").creates.length); // 1 console.log(changes.of("Comment").creates.length); // 1 console.log(changes.of("Like").creates.length); // 2 // Batch operations place shallower creates first const batch = changes.toBatchOperations(); // Order: Post → Comment → Like (root to leaf) ``` Entities already present during construction are part of the baseline and are not reported as creates merely because their IDs are new. ## Change History `getHistory()` returns low-level writes observed by the entity's tracker: ```typescript theme={null} order.updateStatus("confirmed"); const history = order.getHistory(); console.log(history[0]); // { // path: "status", // previousValue: "draft", // currentValue: "confirmed", // timestamp: 1710000000000 // } ``` History entries are useful for debugging, but they are not the same as `AggregateChanges`: multiple writes may collapse into one update operation, and changes reverted back to the baseline may produce no persistence operation. Change history is in-memory diagnostic state, not a durable audit log. Persist explicit domain events or audit records when auditability is required. ## Collections with Entities Use entities when collection items need independent create, update, and delete operations. Value Objects remain appropriate for immutable values that are persisted as part of their owner: ```typescript theme={null} class TagReference extends Entity<{ id: Id; tagId: string; name: string }> { get tagId() { return this.props.tagId; } get name() { return this.props.name; } } class Like extends Entity<{ id: Id; postId: string; userId: string; createdAt: Date; }> { get postId() { return this.props.postId; } get userId() { return this.props.userId; } } ``` ### How Entity Tracking Works Entities are tracked by their ID: ```typescript theme={null} const user = new User({ id: Id.from("user-1"), tags: [ new TagReference({ id: Id.from("tag-ref-1"), tagId: "tag-1", name: "JavaScript" }), new TagReference({ id: Id.from("tag-ref-2"), tagId: "tag-2", name: "TypeScript" }), ], }); // Remove a tag user.removeTag("tag-1"); // Add a new tag user.addTag(new TagReference({ id: new Id(), tagId: "tag-3", name: "Node.js" })); const changes = user.getChanges(); const tagChanges = changes.of("TagReference"); console.log(tagChanges.deletes.length); // 1 console.log(tagChanges.creates.length); // 1 ``` ### Nested Entities Example ```typescript theme={null} const comment = new Comment({ id: Id.from("comment-1"), text: "Great post!", likes: [ new Like({ id: new Id(), postId: "post-1", userId: "user-1", createdAt: new Date() }), new Like({ id: new Id(), postId: "post-1", userId: "user-2", createdAt: new Date() }), ], }); // Remove a specific like comment.removeLike("post-1", "user-2"); const changes = comment.getChanges(); const likeChanges = changes.of("Like"); console.log(likeChanges.deletes.length); // 1 ``` ## Type-Safe Changes For better TypeScript support, define an entity map: ```typescript theme={null} type OrderEntities = { Order: Order; OrderItem: OrderItem; ItemDiscount: ItemDiscount; Address: Address; }; // Get typed changes const changes = order.getChanges(); // Now 'of' has autocomplete const itemChanges = changes.of("OrderItem"); // ^? "Order" | "OrderItem" | "ItemDiscount" | "Address" // Returns are properly typed itemChanges.creates.forEach((item) => { console.log(item.quantity); // item is OrderItem }); ``` ### Helper Method Pattern ```typescript theme={null} class Order extends Aggregate { getTypedChanges() { type Entities = { Order: Order; OrderItem: OrderItem; ItemDiscount: ItemDiscount; }; return this.getChanges(); } } // Usage const changes = order.getTypedChanges(); const items = changes.of("OrderItem"); // Fully typed! ``` We strongly recommend defining a `getTypedChanges()` helper method directly in your Aggregate class. This provides a cleaner API and avoids repeating the entity map every time: ```typescript theme={null} class Order extends Aggregate { getTypedChanges() { interface Entities { OrderItem: OrderItem; ItemDiscount: ItemDiscount; } return this.getChanges(); } } // Usage - clean and type-safe const changes = order.getTypedChanges(); const items = changes.of("OrderItem"); // Autocomplete works! ``` Entity-map keys must match the runtime class names stored in operations. For example, an `Address` instance produces the entity name `"Address"`, not the property name `"shippingAddress"`. ## Persistence Example with Prisma `PrismaToPersistence` uses `PrismaBatchExecutor` for updates by default. The executor understands relation metadata, schema mappings, owned collections, and reference collections, while `PrismaRepository.save()` resets the aggregate with `markAsPersisted()` after persistence: ```typescript theme={null} class OrderService { constructor(private readonly orderRepo: OrderRepository) {} async confirmOrder(orderId: string): Promise { const order = await this.orderRepo.findById(orderId); if (!order) throw new Error("Order not found"); order.confirm(); const changes = order.getTypedChanges(); if (changes.isEmpty()) return; await this.orderRepo.save(order); console.log(order.getChanges().isEmpty()); // true } } ``` Override `PrismaToPersistence.onUpdate()` only when part of the graph needs custom persistence. Use `without()` to pass the remaining operations to the default executor. ## API Reference ### BaseEntity Tracking These methods are available on both `Entity` and `Aggregate`: | Method | Returns | Description | | -------------------------- | ------------------------------ | ------------------------------------------------------------ | | `getChanges()` | `AggregateChanges` | Calculate operations since the current baseline | | `getHistory()` | `HistoryEntry[]` | Copy of low-level writes observed by the tracker | | `markAsClean()` | `void` | Recursively establish a new baseline | | `markAsPersisted()` | `void` | Recursively establish a new baseline and mark IDs as not new | ### AggregateChanges | Method | Returns | Description | | ----------------------------- | ------------------- | ------------------------------------------------ | | `creates()` | `CreateOperation[]` | All creates, ordered root → leaf | | `updates()` | `UpdateOperation[]` | All updates | | `deletes()` | `DeleteOperation[]` | All deletes, ordered leaf → root | | `of(entity)` | `EntityChanges` | Filter changes by entity name | | `forRelation(relation)` | `AggregateChanges` | New instance filtered by relation field | | `without(entity \| entities)` | `AggregateChanges` | New instance excluding the given entity/entities | | `toBatchOperations()` | `BatchOperations` | Group changes for bulk operations | | `hasCreates()` | `boolean` | Has any creates | | `hasUpdates()` | `boolean` | Has any updates | | `hasDeletes()` | `boolean` | Has any deletes | | `hasChanges()` | `boolean` | Has any operations | | `isEmpty()` | `boolean` | No operations | | `getAffectedEntities()` | `string[]` | List of entity names with changes | | `getAffectedRelations()` | `string[]` | List of relation fields with changes | | `operations()` | `Generator` | Iterate all operations in order | | `toArray()` | `Operation[]` | All operations as array | | `rawOperations` | `Operation[]` | Copy of operations without ordering | | `clone()` | `AggregateChanges` | Independent copy of the operation list | | `clear()` | `void` | Clear this result object's operations | | `count` | `number` | Total number of operations | `AggregateChanges.clear()` only mutates that result object. It does not reset the entity tracker; a later `entity.getChanges()` will calculate the changes again. Use `entity.markAsClean()` to establish a new baseline. ### EntityChanges | Property/Method | Returns | Description | | --------------- | ------------------------------------- | ----------------------------------- | | `creates` | `T[]` | Created entities | | `updates` | `Array<{entity: T, changed: Record}>` | Updated entities with changes | | `deletes` | `T[]` | Deleted entities | | `createIds` | `string[]` | IDs of created entities | | `updateIds` | `string[]` | IDs of updated entities | | `deleteIds` | `string[]` | IDs of deleted entities | | `hasCreates()` | `boolean` | Has any creates | | `hasUpdates()` | `boolean` | Has any updates | | `hasDeletes()` | `boolean` | Has any deletes | | `hasChanges()` | `boolean` | Has any operations | | `isEmpty()` | `boolean` | No operations | | `rawOperations` | `Operation[]` | Copy of the filtered raw operations | | `count` | `number` | Total operations | ### BatchOperations ```typescript theme={null} interface BatchOperations { deletes: Array<{ entity: string; depth: number; ids: string[]; parentId?: string; parentEntity?: string; relationField?: string; items?: Array<{ id: string; relationField?: string; }>; }>; creates: Array<{ entity: string; depth: number; parentEntity?: string; relationField?: string; items: Array<{ data: any; parentId?: string; parentEntity?: string; relationField?: string; }>; }>; updates: Array<{ entity: string; items: Array<{ id: string; changedFields: Record; }>; }>; } ``` # Domain Events Source: https://woltz.mintlify.app/core/domain-events Communicate changes across aggregates with domain events ## What are Domain Events? Domain Events represent something significant that happened in your domain. They enable loose coupling between aggregates and support event-driven architectures. The Rich Domain core library provides **interfaces only** - you implement the event publishing mechanism according to your needs (in-memory, message queues, event streams, etc.). ```typescript theme={null} import { DomainEvent } from "@woltz/rich-domain"; type OrderConfirmedPayload = { customerId: string; total: number; }; export class OrderConfirmedEvent extends DomainEvent {} ``` ## Creating Events ### Basic Event Events are created by extending `DomainEvent` with a typed payload: ```typescript theme={null} type UserCreatedPayload = { email: string; }; export class UserCreatedEvent extends DomainEvent {} ``` ### Event with Queue Name (Optional) You can specify a static `queueName` for routing events to specific queues (useful for message queue implementations): ```typescript theme={null} type SendEmailPayload = { to: string; subject: string; body: string; }; export class SendEmailNotification extends DomainEvent { static readonly queueName = "notification-events"; } ``` ### Event Properties Every domain event automatically has these properties: | Property | Type | Description | | ------------ | -------- | ------------------------------------------- | | `eventId` | `string` | Unique identifier for this event occurrence | | `eventName` | `string` | Name of the event (class name) | | `occurredOn` | `Date` | When the event occurred | | `payload` | `P` | The event data (typed) | | `queueName` | `string` | Optional queue name (static property) | ```typescript theme={null} const event = new UserCreatedEvent({ email: "john@example.com", }); console.log(event.eventId); // "1699876543210-abc123def" console.log(event.eventName); // "UserCreatedEvent" console.log(event.occurredOn); // 2024-12-27T10:30:00.000Z console.log(event.payload); // { email: "john@example.com" } ``` ## Raising Events from Aggregates Use `addDomainEvent()` inside aggregate methods to record events: ```typescript theme={null} import { Aggregate } from "@woltz/rich-domain"; class User extends Aggregate { static create(props: Omit): User { const user = new User(props); // Add event - it will be stored in the aggregate user.addDomainEvent( new UserCreatedEvent({ email: user.email, }) ); return user; } activate() { if (this.props.status === "active") return; this.props.status = "active"; this.addDomainEvent( new UserActivatedEvent({ userId: this.id.value }) ); } changeEmail(newEmail: string) { const oldEmail = this.props.email; this.props.email = newEmail; this.addDomainEvent( new UserEmailChangedEvent({ oldEmail, newEmail }) ); } } ``` ## Event Bus Interface Rich Domain provides the `IDomainEventBus` interface with two methods: ```typescript theme={null} interface IDomainEventBus { /** * Publish a single domain event */ publish(event: IDomainEvent): Promise; /** * Publish multiple domain events */ publishAll(events: IDomainEvent[]): Promise; } ``` **You implement this interface** according to your infrastructure needs. ## Publishing Events ### From Aggregates After persisting changes, dispatch all uncommitted events: ```typescript theme={null} import { BullMQEventBus } from "./infrastructure/event-bus"; // In your service/use case async function createUser(data: CreateUserInput) { // 1. Create aggregate (events are recorded) const user = User.create(data); // 2. Save to database await userRepository.save(user); // 3. Dispatch all uncommitted events (clears automatically) await user.dispatchAll(eventBus); return user; } ``` ### Direct Publishing You can also publish events directly without aggregates: ```typescript theme={null} // Single event const event = new UserCreatedEvent({ email: "john@example.com" }); await eventBus.publish(event); // Multiple events const events = [ new UserCreatedEvent({ email: "john@example.com" }), new SendEmailNotification({ to: "john@example.com", subject: "Welcome!", body: "Thanks for joining!", }), ]; await eventBus.publishAll(events); ``` ### Managing Uncommitted Events Aggregates and entities provide methods to manage events: ```typescript theme={null} const user = User.create({ email: "john@example.com" }); // Check if there are uncommitted events console.log(user.hasUncommittedEvents()); // true // Get all uncommitted events const events = user.getUncommittedEvents(); console.log(events); // [UserCreatedEvent] // Clear all events user.clearEvents(); console.log(user.hasUncommittedEvents()); // false ``` ## Event Serialization Events can be serialized to JSON for storage or transmission: ```typescript theme={null} const event = new UserCreatedEvent({ email: "john@example.com" }); const json = event.toJSON(); console.log(json); // { // eventId: "1699876543210-abc123def", // eventName: "UserCreatedEvent", // occurredOn: "2024-12-27T10:30:00.000Z", // payload: { // email: "john@example.com" // } // } ``` ## API Reference ### DomainEvent ```typescript theme={null} abstract class DomainEvent

implements IDomainEvent

{ readonly eventId: string; readonly occurredOn: Date; readonly payload: P; static readonly queueName?: string; constructor(payload: P); get eventName(): string; toJSON(): object; } ``` ### IDomainEventBus ```typescript theme={null} interface IDomainEventBus { publish(event: IDomainEvent): Promise; publishAll(events: IDomainEvent[]): Promise; } ``` ### Aggregate Methods ```typescript theme={null} class BaseAggregate { protected addDomainEvent(event: IDomainEvent): void; getUncommittedEvents(): IDomainEvent[]; clearEvents(): void; hasUncommittedEvents(): boolean; async dispatchAll(bus: IDomainEventBus): Promise; getEvent(eventName: string): IDomainEvent | undefined; hasEvent(eventName: string): boolean; } ``` # Entities & Aggregates Source: https://woltz.mintlify.app/core/entities-and-aggregates Domain objects with identity that form the core building blocks of your domain model ## What are Entities and Aggregates? Entities and Aggregates are domain objects defined by their **identity**, not by their attributes. Two objects with the same attributes but different IDs are considered distinct objects. The main difference lies in their architectural role, not in their change tracking implementation: * **Entity**: Domain object with identity that normally lives **inside** an Aggregate * **Aggregate**: Root entity that defines a **consistency boundary** and is the entry point for repositories and domain events Objects with identity that live inside Aggregates. Not accessed directly through repositories. Root of a cluster of related entities. Models the consistency boundary persisted by a repository. ## Key Differences | Feature | Entity | Aggregate | | -------------------------------------- | ------ | --------- | | Has identity | Yes | Yes | | Participates in persistence | Yes | Yes | | Defines a consistency boundary | No | Yes | | Repository root in the core API | No | Yes | | Can contain and manage child entities | Yes | Yes | | Can record domain events | No | Yes | | Tracks its own and nested changes | Yes | Yes | | Can technically be nested in an object | Yes | Yes | `Entity` and `Aggregate` both inherit change tracking from `BaseEntity`. Therefore, either one can technically contain and track nested entities. The distinction is architectural: repositories in the core API operate on aggregate roots, and only `Aggregate` provides domain-event management. Although nesting one `Aggregate` inside another is technically supported by the object graph tracker, it usually crosses aggregate boundaries. Prefer referencing another aggregate root by `Id`. Persistence adapters may treat an embedded aggregate as an owned child unless its relationship is configured as a reference. ## Creating Entities Entities are identity-bearing domain objects, typically used as part of an Aggregate. They have the same validation, serialization, and change tracking capabilities inherited from `BaseEntity`, but they do not manage domain events: ```typescript theme={null} import { Entity, Id, DomainError } from "@woltz/rich-domain"; interface OrderItemProps { id: Id; productId: string; quantity: number; unitPrice: number; } class OrderItem extends Entity { get productId() { return this.props.productId; } get quantity() { return this.props.quantity; } get subtotal() { return this.props.quantity * this.props.unitPrice; } updateQuantity(quantity: number) { if (quantity <= 0) { throw new DomainError("Quantity must be positive"); } this.props.quantity = quantity; } } // New entity (auto-generated ID) const item = new OrderItem({ productId: "prod-123", quantity: 2, unitPrice: 49.99, }); console.log(item.isNew()); // true // Existing entity (from database) const existingItem = new OrderItem({ id: Id.from("item-456"), productId: "prod-123", quantity: 2, unitPrice: 49.99, }); console.log(existingItem.isNew()); // false ``` ## Creating Aggregates Aggregates are the root of a cluster of entities and define consistency boundaries: ```typescript theme={null} import { Aggregate, Id, DomainError } from "@woltz/rich-domain"; interface OrderProps { id: Id; customerId: string; status: "draft" | "confirmed" | "shipped" | "delivered"; items: OrderItem[]; shippingAddress: Address; createdAt: Date; } class Order extends Aggregate { 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); } addItem(productId: string, quantity: number, unitPrice: number) { if (this.props.status !== "draft") { throw new DomainError("Cannot modify confirmed order"); } this.props.items.push(new OrderItem({ productId, quantity, unitPrice })); } removeItem(itemId: Id) { if (this.props.status !== "draft") { throw new DomainError("Cannot modify confirmed order"); } this.props.items = this.props.items.filter( (item) => !item.id.equals(itemId) ); } confirm() { if (this.props.items.length === 0) { throw new DomainError("Cannot confirm empty order"); } this.props.status = "confirmed"; } ship() { if (this.props.status !== "confirmed") { throw new DomainError("Order must be confirmed before shipping"); } this.props.status = "shipped"; } } ``` ## Identity & Equality Both Entities and Aggregates are compared by ID, not by attributes: ```typescript theme={null} const item1 = new OrderItem({ id: Id.from("item-1"), productId: "prod-123", quantity: 2, unitPrice: 49.99, }); const item2 = new OrderItem({ id: Id.from("item-1"), productId: "prod-999", // different attributes quantity: 10, unitPrice: 99.99, }); const item3 = new OrderItem({ id: Id.from("item-2"), // different ID productId: "prod-123", quantity: 2, unitPrice: 49.99, }); item1.equals(item2); // true - same ID item1.equals(item3); // false - different IDs item1.equals("item-1"); // true - can compare with string item1.equals(Id.from("item-1")); // true - can compare with Id ``` ## Validation Both support validation with Standard Schema-compatible validators such as Zod: ```typescript theme={null} import { z } from "zod"; import { Aggregate, DomainError, Entity, EntityHooks, EntityValidation, Id, } from "@woltz/rich-domain"; // Entity validation const orderItemSchema = z.object({ id: z.custom((val) => val instanceof Id), productId: z.string().min(1), quantity: z.number().int().positive(), unitPrice: z.number().positive(), }); type OrderItemProps = z.infer; class OrderItem extends Entity { protected static validation: EntityValidation = { schema: orderItemSchema, config: { onCreate: true, onUpdate: true, throwOnError: true, }, }; } // Aggregate validation with business rules const orderSchema = z.object({ id: z.custom((val) => val instanceof Id), customerId: z.string().min(1), status: z.enum(["draft", "confirmed", "shipped", "delivered"]), items: z.array(z.custom((val) => val instanceof OrderItem)), shippingAddress: z.custom

((val) => val instanceof Address), createdAt: z.date(), }); type OrderProps = z.infer; class Order extends Aggregate { protected static validation: EntityValidation = { schema: orderSchema, config: { onCreate: true, onUpdate: true, throwOnError: true, }, }; protected static hooks: EntityHooks = { rules: (order) => { // Business rule: shipped orders must have at least one item if (order.status === "shipped" && order.items.length === 0) { throw new DomainError("Shipped orders must have items"); } }, }; } ``` ## Optional Input Properties Sometimes you need properties that are required in the entity but optional when constructing it, because they're generated internally via hooks. Use the second generic parameter to specify optional input fields: ```typescript theme={null} import { z } from "zod"; import { Aggregate, Id, EntityValidation, EntityHooks } from "@woltz/rich-domain"; const userSchema = z.object({ id: z.custom((val) => val instanceof Id), email: z.string().email(), password: z.string().min(8), // Required in schema name: z.string(), createdAt: z.date(), // Required in schema }); type UserProps = z.infer; // Make 'password' and 'createdAt' optional in constructor class User extends Aggregate { protected static validation: EntityValidation = { schema: userSchema, }; protected static hooks: EntityHooks = { onBeforeCreate: (props) => { // Generate password if not provided if (!props.password) { props.password = generateEncryptedPassword(); } // Set createdAt if not provided if (!props.createdAt) { props.createdAt = new Date(); } }, }; get email() { return this.props.email; } get password() { return this.props.password; } } // ✅ Works without password and createdAt const user = new User({ email: "user@example.com", name: "John Doe", }); // ✅ Also works with explicit values const userWithPassword = new User({ email: "user@example.com", name: "John Doe", password: "custom-password-12345678", }); ``` Fields marked as optional input are still **required in the entity** and validated by the schema. The difference is they're optional when calling `new User()` because they'll be generated in `onBeforeCreate`. **Common Use Cases:** * Auto-generated passwords or tokens * Timestamps (createdAt, updatedAt) * Computed identifiers * Default configurations ## Consistency Boundaries Model aggregate methods so their invariants are valid before persistence. Coordinate changes across aggregate boundaries with domain events or application services. Rich Domain supplies the building blocks, but it does not automatically define your invariants or open a database transaction. The application and persistence adapter remain responsible for executing a save inside the required transactional boundary. ## Change Tracking Both entities and aggregates track changes to their own properties and nested `BaseEntity` instances. In normal DDD usage, call `getChanges()` on the aggregate root so the result represents the persistence boundary: ```typescript theme={null} const order = new Order({ id: Id.from("order-123"), customerId: "cust-456", status: "draft", items: [ new OrderItem({ id: Id.from("item-1"), productId: "prod-a", quantity: 2, unitPrice: 29.99, }), new OrderItem({ id: Id.from("item-2"), productId: "prod-b", quantity: 1, unitPrice: 19.99, }), ], shippingAddress: new Address({ street: "123 Main", city: "NYC", zipCode: "10001", }), createdAt: new Date(), }); // Make changes after the initial tracking baseline order.addItem("prod-c", 1, 49.99); // Create order.items[0].updateQuantity(5); // Update order.removeItem(Id.from("item-2")); // Delete // Get all changes const changes = order.getChanges(); console.log(changes.hasCreates()); // true console.log(changes.hasUpdates()); // true console.log(changes.hasDeletes()); // true // Group and order operations for a persistence adapter const batch = changes.toBatchOperations(); console.log(batch.deletes[0].ids); // ["item-2"] console.log(batch.creates[0].items[0].parentId); // "order-123" console.log(batch.updates[0].items[0].changedFields); // { quantity: 5 } ``` Construction establishes the initial tracking baseline. `getChanges()` reports mutations made after construction or after `markAsClean()`; it does not emit a create operation for the root object itself. Repositories use `isNew()` to decide whether the aggregate root requires an INSERT. ## Domain Events Aggregates can emit domain events for cross-aggregate communication: ```typescript theme={null} import { DomainEvent } from "@woltz/rich-domain"; type OrderConfirmedPayload = { orderId: string; customerId: string; total: number; }; class OrderConfirmedEvent extends DomainEvent {} class Order extends Aggregate { confirm() { if (this.props.items.length === 0) { throw new DomainError("Cannot confirm empty order"); } this.props.status = "confirmed"; this.addDomainEvent(new OrderConfirmedEvent({ orderId: this.id.value, customerId: this.customerId, total: this.total, })); } } // Persist first, then publish. dispatchAll() clears events after publishing. await orderRepository.save(order); await order.dispatchAll(eventBus); ``` See more in [Domain Events](/core/domain-events) ## Serialization Both can be converted to JSON for APIs or persistence: ```typescript theme={null} const item = new OrderItem({ id: Id.from("item-123"), productId: "prod-456", quantity: 3, unitPrice: 29.99, }); const itemJson = item.toJSON(); // { // id: "item-123", // productId: "prod-456", // quantity: 3, // unitPrice: 29.99 // } const orderJson = order.toJSON(); // { // id: "order-123", // customerId: "cust-456", // status: "confirmed", // items: [ // { id: "item-1", productId: "prod-a", quantity: 2, unitPrice: 29.99 } // ], // shippingAddress: { street: "123 Main", city: "NYC", zipCode: "10001" }, // createdAt: "2024-01-15T10:30:00.000Z" // } ``` # Id Source: https://woltz.mintlify.app/core/id Smart identity management that knows if an entity is new or existing ## What is Id? The `Id` class is a smart wrapper for entity identifiers. It automatically tracks whether an entity is new (needs INSERT) or existing (needs UPDATE). ```typescript theme={null} import { Id } from "@woltz/rich-domain"; // New entity - generates UUID automatically const newId = new Id(); console.log(newId.isNew()); // true console.log(newId.value); // "550e8400-e29b-41d4-a716-446655440000" // Existing entity - uses provided value const existingId = new Id("user-123"); console.log(existingId.isNew()); // false console.log(existingId.value); // "user-123" ``` ## Creating IDs ### Auto-generated (New Entity) ```typescript theme={null} // Using constructor const id1 = new Id(); // Using static method const id2 = Id.create(); id1.isNew(); // true id2.isNew(); // true ``` ### From Existing Value (From Database) ```typescript theme={null} // Using constructor const id1 = new Id("user-123"); // Using static method const id2 = Id.from("user-123"); id1.isNew(); // false id2.isNew(); // false ``` ## Why This Matters The `isNew()` method enables repositories to decide between INSERT and UPDATE: ```typescript theme={null} class UserRepository { async save(user: User): Promise { if (user.isNew()) { await this.insert(user); } else { await this.update(user); } } } ``` ## Comparison & Equality IDs can be compared with other IDs or strings: ```typescript theme={null} const id1 = new Id("user-123"); const id2 = new Id("user-123"); const id3 = new Id("user-456"); // Compare with Id id1.equals(id2); // true id1.equals(id3); // false // Compare with string id1.equals("user-123"); // true id1.equals("user-456"); // false ``` ## Entity Equality Entities use ID for equality comparison: ```typescript theme={null} const user1 = new User({ id: Id.from("user-123"), name: "John", email: "john@example.com", }); const user2 = new User({ id: Id.from("user-123"), name: "John Updated", // Different name email: "john.new@example.com", // Different email }); const user3 = new User({ id: Id.from("user-456"), name: "John", email: "john@example.com", }); user1.equals(user2); // true - same ID user1.equals(user3); // false - different ID // Can also compare entity with Id or string user1.equals(Id.from("user-123")); // true user1.equals("user-123"); // true ``` ## Working with Collections IDs make it easy to find entities in collections: ```typescript theme={null} const users = [ new User({ id: Id.from("1"), name: "Alice" }), new User({ id: Id.from("2"), name: "Bob" }), new User({ id: Id.from("3"), name: "Charlie" }), ]; // Find by Id const searchId = Id.from("2"); const found = users.find((user) => user.equals(searchId)); console.log(found?.name); // "Bob" // Find by string const foundByString = users.find((user) => user.equals("2")); console.log(foundByString?.name); // "Bob" // Filter by Id const filtered = users.filter((user) => user.equals("1")); console.log(filtered.length); // 1 // Check existence const exists = users.some((user) => user.equals("3")); console.log(exists); // true ``` ## Serialization IDs serialize to their string value: ```typescript theme={null} const id = new Id("user-123"); // toString console.log(id.toString()); // "user-123" console.log(String(id)); // "user-123" // JSON console.log(id.toJSON()); // "user-123" console.log(JSON.stringify(id)); // '"user-123"' // In entity toJson() const user = new User({ id: Id.from("user-123"), name: "John", }); const json = user.toJSON(); console.log(json.id); // "user-123" (string, not Id object) ``` ## UUID Generation When no value is provided, `Id` generates a UUID v4: ```typescript theme={null} const id1 = new Id(); const id2 = new Id(); const id3 = new Id(); console.log(id1.value); // "550e8400-e29b-41d4-a716-446655440000" console.log(id2.value); // "6ba7b810-9dad-11d1-80b4-00c04fd430c8" console.log(id3.value); // "f47ac10b-58cc-4372-a567-0e02b2c3d479" // All unique console.log(id1.equals(id2)); // false console.log(id2.equals(id3)); // false ``` ## Practical Example ```typescript theme={null} // Domain model class Order extends Aggregate { static create(customerId: string): Order { return new Order({ // New order - auto-generated ID customerId, status: "draft", items: [], createdAt: new Date(), }); } static fromDatabase(data: OrderRow): Order { return new Order({ // Existing order - ID from database id: Id.from(data.id), customerId: data.customer_id, status: data.status, items: data.items.map( (item) => new OrderItem({ id: Id.from(item.id), productId: item.product_id, quantity: item.quantity, }) ), createdAt: data.created_at, }); } } // Usage const newOrder = Order.create("customer-123"); console.log(newOrder.isNew()); // true const existingOrder = Order.fromDatabase(dbRow); console.log(existingOrder.isNew()); // false ``` ## API Reference ### Constructor ```typescript theme={null} new Id(); // Generates UUID, isNew = true new Id("value"); // Uses provided value, isNew = false // With explicit isNew flag new Id("value", true); // Uses provided value, isNew = true (override) new Id("value", false); // Uses provided value, isNew = false (default) ``` ### Static Methods | Method | Description | | ------------------ | ---------------------------------------------------- | | `Id.create()` | Creates a new Id with auto-generated UUID | | `Id.from(value)` | Creates an Id from an existing value | | `Id.compose(a, b)` | Composes two IDs (Id or string) into a single string | ### Instance Methods | Method | Returns | Description | | ---------------- | --------- | ------------------------------------------ | | `isNew()` | `boolean` | True if ID was auto-generated | | `equals(other)` | `boolean` | Compare with Id or string | | `toString()` | `string` | Returns the ID value | | `toJSON()` | `string` | Returns the ID value (for serialization) | | `markAsNotNew()` | `void` | Marks the Id as not new (after persisting) | | `markAsNew()` | `void` | Marks the Id as new | ### Properties | Property | Type | Description | | -------- | -------- | ------------------- | | `value` | `string` | The ID string value | # Value Objects Source: https://woltz.mintlify.app/core/value-objects Immutable primitives with domain behavior and validation ## What is a Value Object? A Value Object is an immutable wrapper around a primitive value (string, number, boolean, or Date) that encapsulates domain behavior and validation. Two Value Objects with the same value are considered equal. ```typescript theme={null} import { ValueObject } from "@woltz/rich-domain"; class Email extends ValueObject { getDomain(): string { return this.value.split("@")[1]; } isBusinessEmail(): boolean { const freeProviders = ["gmail.com", "yahoo.com", "hotmail.com"]; return !freeProviders.includes(this.getDomain()); } } const email = new Email("john@example.com"); console.log(email.value); // "john@example.com" console.log(email.getDomain()); // "example.com" console.log(email.isBusinessEmail()); // true ``` ## Key Characteristics Value Objects wrap a single primitive value: string, number, boolean, or Date. Once created, a Value Object cannot be changed. Use `clone()` to create new instances. Two Value Objects are equal if their primitive values are equal. Value Objects contain all the behavior related to the concept they represent. ## Creating Value Objects Value Objects accept only primitive types: ```typescript theme={null} // ✅ Valid primitive types class Age extends ValueObject {} class Email extends ValueObject {} class IsActive extends ValueObject {} class BirthDate extends ValueObject {} // ❌ Invalid - cannot use complex objects class Address extends ValueObject<{ street: string; city: string }> {} // Error: Value Objects must use primitive types ``` ## Basic Usage ```typescript theme={null} class UserId extends ValueObject {} const userId = new UserId("user-123"); console.log(userId.value); // "user-123" // Value objects are immutable userId.value = "user-456"; // ❌ Error: Cannot assign to read only property ``` ## Immutability Value Objects are frozen on creation. To "change" a Value Object, use the `clone()` method: ```typescript theme={null} class Price extends ValueObject { addTax(taxRate: number): Price { const newValue = this.value * (1 + taxRate); return this.clone(newValue); } format(): string { return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", }).format(this.value); } } const price = new Price(99.99); const priceWithTax = price.addTax(0.08); console.log(price.value); // 99.99 (unchanged) console.log(priceWithTax.value); // 107.99 (new instance) console.log(priceWithTax.format()); // "$107.99" ``` ## Equality Value Objects are compared by their primitive values: ```typescript theme={null} const email1 = new Email("john@example.com"); const email2 = new Email("john@example.com"); const email3 = new Email("jane@example.com"); email1.equals(email2); // true - same value email1.equals(email3); // false - different value ``` ## Validation Add schema validation to ensure Value Objects are always valid: ```typescript theme={null} import { z } from "zod"; import { ValueObject } from "@woltz/rich-domain"; const emailSchema = z.string().email("Invalid email format"); class Email extends ValueObject { protected static validation = { schema: emailSchema, config: { onCreate: true, throwOnError: true, }, }; getDomain(): string { return this.value.split("@")[1]; } } // Valid const email = new Email("john@example.com"); // Invalid - throws ValidationError const invalid = new Email("not-an-email"); // ValidationError: Invalid email format ``` ## Lifecycle Hooks Value Objects support lifecycle hooks for custom validation and side effects: ```typescript theme={null} import { ValueObject, VOHooks, throwValidationError, } from "@woltz/rich-domain"; class Age extends ValueObject { protected static hooks: VOHooks = { onBeforeCreate: (value) => { console.log(`Creating age: ${value}`); }, rules: (ageObject) => { if (ageObject.value < 0) { throwValidationError("value", "Age cannot be negative"); } if (ageObject.value > 150) { throwValidationError("value", "Age exceeds maximum limit"); } }, }; isAdult(): boolean { return this.value >= 18; } isMinor(): boolean { return this.value < 18; } } const age = new Age(25); console.log(age.isAdult()); // true // Invalid - throws ValidationError const invalidAge = new Age(-5); // ValidationError: Age cannot be negative ``` ### Available Hooks * **`onBeforeCreate`**: Called before validation, receives the primitive value * **`rules`**: Custom business rules, receives the ValueObject instance ## Common Examples ### Email ```typescript theme={null} import { z } from "zod"; const emailSchema = z.string().email(); class Email extends ValueObject { protected static validation = { schema: emailSchema, }; getDomain(): string { return this.value.split("@")[1]; } getUsername(): string { return this.value.split("@")[0]; } isBusinessEmail(): boolean { const freeProviders = ["gmail.com", "yahoo.com", "hotmail.com"]; return !freeProviders.includes(this.getDomain()); } } const email = new Email("john.doe@example.com"); console.log(email.getDomain()); // "example.com" console.log(email.getUsername()); // "john.doe" console.log(email.isBusinessEmail()); // true ``` ### Price ```typescript theme={null} class Price extends ValueObject { protected static hooks: VOHooks = { rules: (price) => { if (price.value < 0) { throwValidationError("value", "Price cannot be negative"); } }, }; addTax(taxRate: number): Price { return this.clone(this.value * (1 + taxRate)); } discount(percentage: number): Price { return this.clone(this.value * (1 - percentage / 100)); } format(currency: string = "USD"): string { return new Intl.NumberFormat("en-US", { style: "currency", currency, }).format(this.value); } } const price = new Price(99.99); const withTax = price.addTax(0.08); const discounted = price.discount(10); console.log(price.format()); // "$99.99" console.log(withTax.format()); // "$107.99" console.log(discounted.format()); // "$89.99" ``` ### Percentage ```typescript theme={null} class Percentage extends ValueObject { protected static hooks: VOHooks = { rules: (percentage) => { if (percentage.value < 0 || percentage.value > 100) { throwValidationError("value", "Percentage must be between 0 and 100"); } }, }; toDecimal(): number { return this.value / 100; } format(): string { return `${this.value}%`; } apply(amount: number): number { return amount * this.toDecimal(); } } const discount = new Percentage(15); console.log(discount.format()); // "15%" console.log(discount.toDecimal()); // 0.15 console.log(discount.apply(100)); // 15 ``` ### Slug ```typescript theme={null} import { z } from "zod"; const slugSchema = z.string().regex(/^[a-z0-9-]+$/); class Slug extends ValueObject { protected static validation = { schema: slugSchema, }; static fromTitle(title: string): Slug { const slug = title .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, ""); return new Slug(slug); } toUrl(baseUrl: string): string { return `${baseUrl}/${this.value}`; } } const slug = Slug.fromTitle("Hello World!"); console.log(slug.value); // "hello-world" console.log(slug.toUrl("https://example.com")); // "https://example.com/hello-world" ``` ## Serialization ValueObjects automatically serialize to their primitive values: ```typescript theme={null} const email = new Email("john@example.com"); const json = email.toJSON(); // "john@example.com" (just the string value) // In entities, ValueObjects are automatically unwrapped class User extends Entity<{ id: Id; email: Email }> {} const user = new User({ id: new Id(), email: new Email("john@example.com") }); const userJson = user.toJSON(); // { id: "...", email: "john@example.com" } ``` # Query Params & Adapters Source: https://woltz.mintlify.app/criteria/advanced API integration, field mapping adapters, and advanced features ## Query Params Integration Criteria can be constructed directly from URL query parameters, making it perfect for REST APIs. ### Basic Usage ```typescript theme={null} import { Criteria } from "@woltz/rich-domain"; // Express.js example app.get("/users", (req, res) => { const criteria = Criteria.fromQueryParams(req.query); const users = await userRepository.find(criteria); res.json(users.toJSON()); }); ``` ### Query Parameter Format Filters follow the pattern: `field:operator=value`, nested under the `filters` key: ```typescript theme={null} const criteria = Criteria.fromQueryParams({ filters: { "name:contains": "john", "age:greaterThan": "18", "status:equals": "active", }, }); ``` ### Supported Parameters | Parameter | Key in `QueryParamsObject` | Type | Example | | --------------- | -------------------------- | --------------------------------- | -------------------------------- | | Filters | `filters` | `Record` | `{ "age:greaterThan": "18" }` | | Ordering | `orderBy` | `string[]` | `["createdAt:desc"]` | | Multiple orders | `orderBy` | `string[]` | `["featured:desc", "price:asc"]` | | Pagination | `pagination` | `{ page: number, limit: number }` | `{ page: 2, limit: 20 }` | | Search | `search` | `string` | `"laptop"` | ### Complete Example ```typescript theme={null} const criteria = Criteria.fromQueryParams({ filters: { "status:equals": "active", "price:lessThanOrEqual": "100", "category:in": ["electronics", "accessories"], }, search: "wireless", orderBy: ["featured:desc", "price:asc"], pagination: { page: 1, limit: 24 }, }); ``` ### Value Parsing Values are automatically parsed to their correct types: ```typescript theme={null} // Numbers "age:greaterThan": "25" → 25 (number) // Booleans "active:equals": "true" → true (boolean) "active:equals": "false" → false (boolean) // Dates (ISO format) "createdAt:greaterThan": "2024-01-01T00:00:00Z" → Date object // Arrays (comma-separated) "status:in": "active,pending,draft" → ["active", "pending", "draft"] // Between (two values) "price:between": "10,100" → [10, 100] ``` ### Nested Fields in Query Params ```typescript theme={null} const criteria = Criteria.fromQueryParams({ filters: { "profile.location.city:equals": "New York", "settings.theme:equals": "dark", }, }); ``` ### Quantifiers in Query Params Use `@quantifier` suffix for array field quantifiers: ```typescript theme={null} const criteria = Criteria.fromQueryParams({ filters: { "posts.views:greaterThan@some": "1000", // Any post with > 1000 views "posts.published:equals@every": "true", // All posts published "comments.flagged:equals@none": "true", // No flagged comments }, }); ``` ## Field Adapters Adapters map domain field names to database column names, allowing you to maintain clean API contracts while using different database schemas. ### Why Use Adapters? ```typescript theme={null} // Your domain model uses camelCase interface UserDto { id: string; firstName: string; lastName: string; emailAddress: string; createdAt: Date; } // But your database uses snake_case // users table: id, first_name, last_name, email_address, created_at ``` ### Creating an Adapter ```typescript theme={null} import { CriteriaAdapter } from "@woltz/rich-domain"; const UserAdapter: CriteriaAdapter = { firstName: "first_name", lastName: "last_name", emailAddress: "email_address", createdAt: "created_at", }; ``` ### Using an Adapter ```typescript theme={null} // Method 1: With useAdapter() const criteria = Criteria.create() .useAdapter(UserAdapter) .whereEquals("firstName", "John") // Becomes: first_name = 'John' .orderByDesc("createdAt"); // Becomes: ORDER BY created_at DESC // Method 2: With fromQueryParams() const criteria = Criteria.fromQueryParams( { "firstName:equals": "John" }, UserAdapter ); // Method 3: With fromObject() const criteria = Criteria.fromObject( { filters: [{ field: "firstName", operator: "equals", value: "John" }] }, UserAdapter ); ``` ### Nested Field Adapters Map nested paths to different structures: ```typescript theme={null} interface OrderDto { id: string; customer: { name: string; email: string; }; items: { productName: string; quantity: number; }[]; } interface OrderInDb { id: string; customer_name: string; customer_email: string; order_items: { product_name: string; qty: number; }[]; } const OrderAdapter: CriteriaAdapter = { "customer.name": "customer_name", "customer.email": "customer_email", items: "order_items", "items.productName": "order_items.product_name", "items.quantity": "order_items.qty", }; const criteria = Criteria.create() .useAdapter(OrderAdapter) .whereContains("customer.name", "John") // → customer_name LIKE '%John%' .where("items.quantity", "greaterThan", 5); // → order_items.qty > 5 ``` ### Adapter with Prefix Matching Adapters support prefix matching for nested paths: ```typescript theme={null} const adapter: CriteriaAdapter = { user: "app_user", // Maps all user.* paths }; // user.name → app_user.name // user.profile.bio → app_user.profile.bio ``` ### Getting the Adapter ```typescript theme={null} const criteria = Criteria.create().useAdapter(UserAdapter); const adapter = criteria.getAdapter(); // Returns the adapter or undefined ``` ## Serialization ### toJSON() Convert criteria to a plain object: ```typescript theme={null} const criteria = Criteria.create() .whereEquals("status", "active") .where("age", "greaterThan", 18) .orderByDesc("createdAt") .search("john") .paginate(2, 20); const json = criteria.toJSON(); ``` Result: ```json theme={null} { "filters": [ { "field": "status", "operator": "equals", "value": "active" }, { "field": "age", "operator": "greaterThan", "value": 18 } ], "orders": [{ "field": "createdAt", "direction": "desc" }], "pagination": { "page": 2, "limit": 20, "offset": 20 }, "search": 'john' } ``` ### toQueryObject() Convert criteria to a `QueryParamsObject` — useful when you need the structured object before converting to URL params: ```typescript theme={null} const criteria = Criteria.create() .whereEquals("status", "active") .where("age", "greaterThan", 18) .orderByDesc("createdAt") .paginate(2, 20); const obj = criteria.toQueryObject(); // { // filters: { "status:equals": "active", "age:greaterThan": "18" }, // orders: [{ field: "createdAt", direction: "desc" }], ← resolved via adapter // orderBy: ["createdAt:desc"], // pagination: { page: 2, limit: 20, offset: 20 }, // } ``` ### toQueryParams() Convert criteria directly to `URLSearchParams` for use in HTTP requests: ```typescript theme={null} const criteria = Criteria.create() .whereEquals("status", "active") .orderByDesc("createdAt") .paginate(1, 20); const params = criteria.toQueryParams(); // URLSearchParams { // filters → '{"status:equals":"active"}', // orderBy → '["createdAt:desc"]', // page → "1", // limit → "20", // } // Use in fetch const response = await fetch(`/api/users?${params.toString()}`); ``` `toQueryParams()` and `fromQueryParams()` use the `QueryParamsObject` structure (with a `filters` key). When integrating with an HTTP server, parse the `filters` query param as JSON before passing it to `fromQueryParams`. ### fromObject() Reconstruct criteria from a plain object: ```typescript theme={null} const criteria = Criteria.fromObject({ filters: [ { field: "status", operator: "equals", value: "active" }, { field: "age", operator: "greaterThan", value: 18 }, ], orders: [{ field: "createdAt", direction: "desc" }], pagination: { page: 2, limit: 20, offset: 20 }, search: "john", }); ``` ### Use Cases for Serialization Send criteria from frontend to backend Cache query configurations Store user's saved filter presets Log query configurations for debugging ## Cloning Create independent copies for query variations: ```typescript theme={null} const baseCriteria = Criteria.create() .whereEquals("status", "active") .whereNotNull("email") .orderByDesc("createdAt"); // Clone for different use cases const adminUsers = baseCriteria .clone() .whereEquals("role", "admin") .paginate(1, 10); const recentUsers = baseCriteria .clone() .where("createdAt", "greaterThan", lastWeek) .paginate(1, 50); const searchResults = baseCriteria .clone() .search(searchQuery) .limit(20); // Original is unchanged console.log(baseCriteria.getFilters().length); // 2 (status, email) ``` ## Error Handling ### Invalid Operator ```typescript theme={null} try { const criteria = Criteria.create().where("age", "contains", 18); // contains not valid for number } catch (error) { // InvalidCriteriaError: Operator "contains" is not valid for type "number". // Valid operators: equals, notEquals, greaterThan, ... } ``` ### Invalid Quantifier ```typescript theme={null} try { const criteria = Criteria.fromQueryParams({ "posts.title:contains@invalid": "test", }); } catch (error) { // InvalidCriteriaError: Invalid quantifier. Valid values: some, every, none } ``` ## Express.js Integration Example ```typescript theme={null} import express from "express"; import { Criteria, PaginatedResult } from "@woltz/rich-domain"; const app = express(); // Middleware to parse criteria from query params function parseCriteria(adapter?: CriteriaAdapter) { return ( req: express.Request, res: express.Response, next: express.NextFunction ) => { try { req.criteria = Criteria.fromQueryParams(req.query, adapter); next(); } catch (error) { res.status(400).json({ error: error.message }); } }; } // Usage app.get("/users", parseCriteria(UserAdapter), async (req, res) => { const criteria = req.criteria; const result = await userRepository.find(criteria); res.json(result.toJSON()); }); app.get( "/products", parseCriteria(ProductAdapter), async (req, res) => { const criteria = req.criteria .whereEquals("published", true) // Add server-side filter .whereNull("deletedAt"); const result = await productRepository.find(criteria); res.json(result.toJSON()); } ); ``` ## Fastify Integration Example ```typescript theme={null} import Fastify from "fastify"; import { Criteria } from "@woltz/rich-domain"; const fastify = Fastify(); fastify.get("/users", async (request, reply) => { const criteria = Criteria.fromQueryParams( request.query as Record, UserAdapter ); const result = await userRepository.find(criteria); return result.toJSON(); }); ``` ## Frontend Integration Example ```typescript theme={null} // React hook for building query strings function useCriteriaQueryString(criteria: Criteria): string { const json = criteria.toJSON(); const params = new URLSearchParams(); // Add filters json.filters.forEach((filter) => { const key = `${filter.field}:${filter.operator}`; const value = Array.isArray(filter.value) ? filter.value.join(",") : String(filter.value); params.set(key, value); }); // Add ordering if (json.orders.length > 0) { params.set( "orderBy", json.orders.map((o) => `${o.field}:${o.direction}`).join(",") ); } // Add pagination if (json.pagination) { params.set("page", String(json.pagination.page)); params.set("limit", String(json.pagination.limit)); } // Add search if (json.search) { params.set("search", json.search); } return params.toString(); } // Usage const criteria = Criteria.create() .whereEquals("category", "electronics") .orderByAsc("price") .paginate(1, 20); const queryString = useCriteriaQueryString(criteria); // "category:equals=electronics&orderBy=price:asc&page=1&limit=20" const response = await fetch(`/api/products?${queryString}`); ``` ## CriteriaAdapter Type Reference ```typescript theme={null} type CriteriaAdapter = { [K in FieldPath]?: FieldPath; }; // Example interface SourceType { userName: string; userEmail: string; profile: { avatar: string; }; } interface TargetType { user_name: string; user_email: string; user_profile: { avatar_url: string; }; } const adapter: CriteriaAdapter = { userName: "user_name", userEmail: "user_email", profile: "user_profile", "profile.avatar": "user_profile.avatar_url", }; ``` # Filters & Operators Source: https://woltz.mintlify.app/criteria/filters Complete guide to all filter operators and their type-safe usage ## Overview Filters are the core of Criteria. They define conditions that records must match. Each filter consists of a field, an operator, and (usually) a value. ```typescript theme={null} const criteria = Criteria.create() .where("age", "greaterThan", 18) // ↑ ↑ ↑ // field operator value ``` ## Filter Methods ### Generic `where()` The most flexible method - accepts any field, operator, and value: ```typescript theme={null} criteria.where("status", "equals", "active"); criteria.where("age", "greaterThan", 18); criteria.where("email", "contains", "@company.com"); criteria.where("role", "in", ["admin", "moderator"]); ``` ### Shorthand Methods Convenience methods for common operations: ```typescript theme={null} // Equality criteria.whereEquals("status", "active"); // Equivalent to: .where("status", "equals", "active") // String contains criteria.whereContains("name", "john"); // Equivalent to: .where("name", "contains", "john") // Array inclusion criteria.whereIn("role", ["admin", "moderator"]); // Equivalent to: .where("role", "in", ["admin", "moderator"]) // Range criteria.whereBetween("age", 18, 65); // Equivalent to: .where("age", "between", [18, 65]) // Null checks criteria.whereNull("deletedAt"); criteria.whereNotNull("email"); ``` ## Operators by Type ### String Operators | Operator | Description | Example | | ------------ | ------------------------------------- | ------------------------------------------------- | | `equals` | Exact match | `where("name", "equals", "John")` | | `notEquals` | Not equal | `where("status", "notEquals", "deleted")` | | `contains` | Contains substring (case-insensitive) | `where("bio", "contains", "developer")` | | `startsWith` | Starts with prefix | `where("email", "startsWith", "admin")` | | `endsWith` | Ends with suffix | `where("email", "endsWith", "@gmail.com")` | | `in` | In array of values | `where("status", "in", ["active", "pending"])` | | `notIn` | Not in array | `where("role", "notIn", ["banned", "suspended"])` | | `isNull` | Is null/undefined | `where("nickname", "isNull")` | | `isNotNull` | Is not null | `where("email", "isNotNull")` | ```typescript theme={null} const criteria = Criteria.create() .whereEquals("status", "active") .whereContains("name", "john") .where("email", "endsWith", "@company.com") .whereNotNull("verifiedAt"); ``` ### Number Operators | Operator | Description | Example | | -------------------- | ------------------------------ | ------------------------------------------- | | `equals` | Exact match | `where("age", "equals", 25)` | | `notEquals` | Not equal | `where("score", "notEquals", 0)` | | `greaterThan` | Greater than | `where("age", "greaterThan", 18)` | | `greaterThanOrEqual` | Greater than or equal | `where("price", "greaterThanOrEqual", 100)` | | `lessThan` | Less than | `where("quantity", "lessThan", 10)` | | `lessThanOrEqual` | Less than or equal | `where("discount", "lessThanOrEqual", 50)` | | `between` | Between two values (inclusive) | `where("age", "between", [18, 65])` | | `in` | In array of values | `where("status", "in", [1, 2, 3])` | | `notIn` | Not in array | `where("priority", "notIn", [0, -1])` | | `isNull` | Is null | `where("score", "isNull")` | | `isNotNull` | Is not null | `where("rating", "isNotNull")` | ```typescript theme={null} const criteria = Criteria.create() .where("price", "greaterThanOrEqual", 10) .where("price", "lessThan", 100) .whereBetween("stock", 1, 1000) .where("rating", "greaterThan", 4); ``` ### Date Operators | Operator | Description | Example | | -------------------- | ----------------- | ----------------------------------------------------- | | `equals` | Exact match | `where("date", "equals", specificDate)` | | `notEquals` | Not equal | `where("updatedAt", "notEquals", oldDate)` | | `greaterThan` | After date | `where("createdAt", "greaterThan", lastWeek)` | | `greaterThanOrEqual` | On or after | `where("startDate", "greaterThanOrEqual", today)` | | `lessThan` | Before date | `where("expiresAt", "lessThan", now)` | | `lessThanOrEqual` | On or before | `where("deadline", "lessThanOrEqual", endOfMonth)` | | `between` | Within date range | `where("createdAt", "between", [startDate, endDate])` | | `isNull` | Is null | `where("deletedAt", "isNull")` | | `isNotNull` | Is not null | `where("publishedAt", "isNotNull")` | ```typescript theme={null} const now = new Date(); const lastWeek = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); const lastMonth = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); const criteria = Criteria.create
() .where("publishedAt", "greaterThan", lastWeek) .whereBetween("createdAt", lastMonth, now) .whereNull("deletedAt"); ``` ### Boolean Operators | Operator | Description | Example | | ----------- | -------------- | --------------------------------------- | | `equals` | Is true/false | `where("isActive", "equals", true)` | | `notEquals` | Opposite value | `where("isDeleted", "notEquals", true)` | | `isNull` | Is null | `where("isVerified", "isNull")` | | `isNotNull` | Is not null | `where("isAdmin", "isNotNull")` | ```typescript theme={null} const criteria = Criteria.create() .whereEquals("isActive", true) .whereEquals("isVerified", true) .where("isBanned", "notEquals", true); ``` ### Array Operators For array fields in your entity: | Operator | Description | Example | | ----------- | ---------------------------- | ---------------------------------------------- | | `in` | Array contains any of values | `where("tags", "in", ["featured", "popular"])` | | `notIn` | Array doesn't contain any | `where("categories", "notIn", ["archived"])` | | `isNull` | Is null/empty | `where("permissions", "isNull")` | | `isNotNull` | Is not null/empty | `where("roles", "isNotNull")` | ```typescript theme={null} const criteria = Criteria.create
() .where("tags", "in", ["javascript", "typescript"]) .where("categories", "notIn", ["draft", "archived"]); ``` ## Nested Field Paths Filter on nested object properties: ```typescript theme={null} interface User { id: string; name: string; profile: { bio: string; location: { city: string; country: string; }; }; settings: { notifications: boolean; theme: string; }; } const criteria = Criteria.create() .whereContains("profile.bio", "developer") .whereEquals("profile.location.country", "Brazil") .whereEquals("settings.notifications", true); ``` ## Array Item Field Paths Filter on properties of items in arrays: ```typescript theme={null} interface User { id: string; posts: { title: string; published: boolean; views: number; }[]; comments: { text: string; likes: number; }[]; } const criteria = Criteria.create() .whereContains("posts.title", "tutorial") .whereEquals("posts.published", true) .where("comments.likes", "greaterThan", 10); ``` When filtering on array item properties, the filter matches if **any** item in the array matches the condition. For more control, see [Quantifiers](#quantifiers). ## Combining Multiple Filters All filters are combined with AND logic: ```typescript theme={null} const criteria = Criteria.create() .whereEquals("status", "active") // AND .where("age", "greaterThanOrEqual", 18) // AND .whereNotNull("email") // AND .whereContains("name", "john"); // Matches users where: // status = "active" // AND age >= 18 // AND email IS NOT NULL // AND name contains "john" ``` ## Filter Validation The library validates filters at runtime: ```typescript theme={null} // Invalid operator for type const criteria = Criteria.create() .where("age", "contains", "18"); // Throws: InvalidCriteriaError: Operator "contains" is not valid for type "number" // This helps catch configuration errors early ``` ## Quantifiers For array fields, you can specify how the filter should match: ### `whereSome` - Any Item Matches ```typescript theme={null} // Match if ANY post has > 100 views criteria.whereSome("posts.views", "greaterThan", 100); ``` ### `whereEvery` - All Items Match ```typescript theme={null} // Match if ALL posts are published criteria.whereEvery("posts.published", "equals", true); ``` ### `whereNone` - No Item Matches ```typescript theme={null} // Match if NO comment has negative likes criteria.whereNone("comments.likes", "lessThan", 0); ``` ### With Query Params ```typescript theme={null} const criteria = Criteria.fromQueryParams({ filters: { "posts.views:greaterThan@some": "100", }, }); ``` Quantifier support depends on your ORM/database. See the integration guides for specific implementations. ## Real-World Examples ### User Search ```typescript theme={null} function searchUsers(query: string, filters: UserFilters) { let criteria = Criteria.create() .whereEquals("status", "active") .whereNull("deletedAt"); if (filters.role) { criteria = criteria.whereEquals("role", filters.role); } if (filters.minAge) { criteria = criteria.where("age", "greaterThanOrEqual", filters.minAge); } if (filters.verifiedOnly) { criteria = criteria.whereNotNull("verifiedAt"); } if (query) { criteria = criteria.search(query); } return criteria.orderByDesc("createdAt").paginate(1, 20); } ``` ### Product Catalog ```typescript theme={null} function getProducts(filters: ProductFilters) { let criteria = Criteria.create() .whereEquals("published", true) .whereNotNull("stock"); if (filters.category) { criteria = criteria.whereEquals("categoryId", filters.category); } if (filters.priceRange) { criteria = criteria.whereBetween( "price", filters.priceRange.min, filters.priceRange.max ); } if (filters.inStock) { criteria = criteria.where("stock", "greaterThan", 0); } if (filters.rating) { criteria = criteria.where("rating", "greaterThanOrEqual", filters.rating); } if (filters.tags?.length) { criteria = criteria.whereIn("tags", filters.tags); } return criteria; } ``` ### Date Range Queries ```typescript theme={null} function getOrdersByPeriod(period: "today" | "week" | "month" | "year") { const now = new Date(); let startDate: Date; switch (period) { case "today": startDate = new Date(now.setHours(0, 0, 0, 0)); break; case "week": startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); break; case "month": startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); break; case "year": startDate = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000); break; } return Criteria.create() .where("createdAt", "greaterThanOrEqual", startDate) .whereNotNull("completedAt") .orderByDesc("createdAt"); } ``` ## Filter Interface For reference, here's the Filter type structure: ```typescript theme={null} interface Filter { field: TField; operator: FilterOperator; value: TValue; options?: { quantifier?: "some" | "every" | "none"; }; } type FilterOperator = | "equals" | "notEquals" | "greaterThan" | "greaterThanOrEqual" | "lessThan" | "lessThanOrEqual" | "contains" | "startsWith" | "endsWith" | "in" | "notIn" | "between" | "isNull" | "isNotNull"; ``` # Ordering, Pagination & Search Source: https://woltz.mintlify.app/criteria/ordering Sort results, paginate data, and perform multi-field searches ## Ordering ### Basic Ordering Sort results by one or more fields: ```typescript theme={null} const criteria = Criteria.create() .orderBy("name", "asc") // Ascending (A-Z) .orderByDesc("createdAt"); // Descending (newest first) ``` ### Order Methods ```typescript theme={null} // Generic method with direction criteria.orderBy("field", "asc"); // or "desc" // Shorthand methods criteria.orderByAsc("name"); // Ascending criteria.orderByDesc("createdAt"); // Descending ``` ### Multiple Orders Orders are applied in the sequence they're added: ```typescript theme={null} const criteria = Criteria.create() .orderByDesc("featured") // First: featured items first .orderByAsc("price") // Then: lowest price .orderByDesc("rating"); // Finally: highest rating // SQL equivalent: // ORDER BY featured DESC, price ASC, rating DESC ``` ### Ordering Nested Fields Sort by nested object properties: ```typescript theme={null} interface User { id: string; name: string; profile: { reputation: number; joinedAt: Date; }; } const criteria = Criteria.create() .orderByDesc("profile.reputation") .orderByAsc("profile.joinedAt"); ``` ### Type-Safe Ordering Only valid field paths are allowed: ```typescript theme={null} const criteria = Criteria.create() .orderBy("name", "asc") // ✅ Valid .orderBy("profile.bio", "desc") // ✅ Valid nested path .orderBy("invalid", "asc"); // ❌ TypeScript error ``` ## Pagination ### Basic Pagination ```typescript theme={null} const criteria = Criteria.create() .paginate(1, 20); // Page 1, 20 items per page const criteria = Criteria.create() .paginate(3, 10); // Page 3, 10 items per page (offset: 20) ``` ### Limit Only When you just want to limit results without full pagination: ```typescript theme={null} const criteria = Criteria.create() .limit(5); // First 5 results // Equivalent to: criteria.paginate(1, 5); ``` ### Default Pagination Criteria has default pagination to prevent unbounded queries: ```typescript theme={null} const criteria = Criteria.create(); const pagination = criteria.getPagination(); // { page: 1, limit: 20, offset: 0 } ``` ### Pagination Object The `getPagination()` method returns: ```typescript theme={null} interface Pagination { page: number; // Current page (1-based) limit: number; // Items per page offset: number; // Calculated offset for SQL } const criteria = Criteria.create() .paginate(3, 25); criteria.getPagination(); // { page: 3, limit: 25, offset: 50 } ``` ### Checking Pagination State ```typescript theme={null} const criteria = Criteria.create() .paginate(1, 20); criteria.hasPagination(); // true (always true due to defaults) ``` ## Search ### Multi-Field Search Search across multiple fields with a single query: ```typescript theme={null} const criteria = Criteria.create() .search("john"); // Matches users where: // name contains "john" OR email contains "john" OR bio contains "john" ``` ### Search with Other Filters Combine search with regular filters: ```typescript theme={null} const criteria = Criteria.create() .whereEquals("status", "active") .whereEquals("categoryId", "electronics") .search("laptop") .orderByDesc("relevance") .paginate(1, 20); ``` ### Checking Search State ```typescript theme={null} const criteria = Criteria.create() .search("john"); criteria.hasSearch(); // true const search = criteria.getSearch(); // "john" ``` ### Search Behavior When search is applied with `PaginatedResult.fromArray()`, it: 1. Searches across **all** items (ignoring pagination initially) 2. Returns matching items 3. Updates the total count based on matches 4. Then applies pagination to the filtered results ```typescript theme={null} // 1000 users in database const criteria = Criteria.create() .search("john") .paginate(1, 10); const result = PaginatedResult.fromArray(users, criteria); // result.meta.total = 50 (only matching users) // result.data.length = 10 (first page of matches) ``` ## Combining Everything ### Full Query Example ```typescript theme={null} const criteria = Criteria.create() // Filters .whereEquals("status", "published") .where("price", "lessThanOrEqual", 100) .whereIn("category", ["electronics", "accessories"]) .whereNotNull("stock") // Search .search(searchQuery) // Ordering .orderByDesc("featured") .orderByAsc("price") // Pagination .paginate(page, 24); ``` ### Dynamic Query Building ```typescript theme={null} function buildProductQuery(filters: ProductFilters): Criteria { let criteria = Criteria.create() .whereEquals("status", "active"); // Conditional filters if (filters.category) { criteria = criteria.whereEquals("category", filters.category); } if (filters.minPrice !== undefined) { criteria = criteria.where("price", "greaterThanOrEqual", filters.minPrice); } if (filters.maxPrice !== undefined) { criteria = criteria.where("price", "lessThanOrEqual", filters.maxPrice); } if (filters.inStock) { criteria = criteria.where("stock", "greaterThan", 0); } // Search if (filters.query) { criteria = criteria.search(filters.query); } // Ordering switch (filters.sortBy) { case "price-asc": criteria = criteria.orderByAsc("price"); break; case "price-desc": criteria = criteria.orderByDesc("price"); break; case "newest": criteria = criteria.orderByDesc("createdAt"); break; case "popular": criteria = criteria.orderByDesc("salesCount"); break; default: criteria = criteria.orderByDesc("featured").orderByDesc("createdAt"); } // Pagination criteria = criteria.paginate(filters.page || 1, filters.perPage || 24); return criteria; } ``` ## Getting Order Information ```typescript theme={null} const criteria = Criteria.create() .orderByDesc("createdAt") .orderByAsc("name"); const orders = criteria.getOrders(); // [ // { field: "createdAt", direction: "desc" }, // { field: "name", direction: "asc" } // ] criteria.hasOrders(); // true ``` ## Order Interface ```typescript theme={null} interface Order { field: string; direction: "asc" | "desc"; } type OrderDirection = "asc" | "desc"; ``` ## API in Query Params ### Ordering via Query Params ```typescript theme={null} const criteria = Criteria.fromQueryParams({ orderBy: ["price:asc", "createdAt:desc"], }); // Creates orders: // 1. price ASC // 2. createdAt DESC ``` ### Pagination via Query Params ```typescript theme={null} const criteria = Criteria.fromQueryParams({ pagination: { page: 2, limit: 20 }, }); criteria.getPagination(); // { page: 2, limit: 20, offset: 20 } ``` ### Search via Query Params ```typescript theme={null} // URL: /products?search=laptop const criteria = Criteria.fromQueryParams({ search: "laptop", }); ``` ### Complete URL Example ```typescript theme={null} const criteria = Criteria.fromQueryParams({ filters: { "status:equals": "active", "price:lessThan": "100", }, search: "laptop", orderBy: ["price:asc"], pagination: { page: 1, limit: 20 }, }); ``` # Overview Source: https://woltz.mintlify.app/criteria/overview Type-safe, fluent query builder for filtering, ordering, and paginating data ## What is Criteria? Criteria is a type-safe query builder that provides a fluent API for constructing database-agnostic queries. It handles filtering, ordering, pagination, and search in a way that's completely decoupled from your persistence layer. ```typescript theme={null} const criteria = Criteria.create() .whereEquals("status", "active") .where("age", "greaterThan", 18) .whereContains("email", "@company.com") .orderByDesc("createdAt") .paginate(1, 20); // Use with repository const result = await userRepository.find(criteria); ``` ## Why Use Criteria? Field paths, operators, and values are all validated by TypeScript at compile time Works with Prisma, Drizzle, TypeORM, or any persistence layer Convert to/from JSON for API transport or caching Chain methods naturally for readable, maintainable queries ## Basic Usage ### Creating a Criteria ```typescript theme={null} import { Criteria } from "@woltz/rich-domain"; // Create empty criteria const criteria = Criteria.create(); // Or with immediate filtering const criteria = Criteria.create().whereEquals("status", "active"); ``` ### Adding Filters ```typescript theme={null} const criteria = Criteria.create() // Equality .whereEquals("status", "active") // Comparison .where("age", "greaterThan", 18) .where("age", "lessThanOrEqual", 65) // String matching .whereContains("name", "john") // Array inclusion .whereIn("role", ["admin", "moderator"]) // Range .whereBetween("salary", 50000, 100000) // Null checks .whereNotNull("email"); ``` ### Ordering Results ```typescript theme={null} const criteria = Criteria.create() .orderBy("name", "asc") .orderByDesc("createdAt"); ``` ### Pagination ```typescript theme={null} const criteria = Criteria.create().paginate(1, 20); // page 1, 20 items per page // Or just limit const criteria = Criteria.create().limit(10); ``` ### Search ```typescript theme={null} const criteria = Criteria.create().search("john"); ``` ## Type Safety Criteria leverages TypeScript to provide compile-time validation: ### Field Paths Only valid field paths from your type are allowed: ```typescript theme={null} interface User { id: string; name: string; email: string; profile: { bio: string; avatar: string; }; posts: Post[]; } const criteria = Criteria.create() .whereEquals("name", "John") // ✅ Valid .whereEquals("profile.bio", "Hello") // ✅ Valid nested path .whereEquals("posts.title", "Post") // ✅ Valid array item path .whereEquals("invalid", "value"); // ❌ TypeScript error ``` ### Operators Per Type Operators are validated based on the field type: ```typescript theme={null} const criteria = Criteria.create() // String fields .where("name", "contains", "john") // ✅ Valid .where("name", "greaterThan", "john") // ❌ Error: greaterThan not valid for string // Number fields .where("age", "greaterThan", 18) // ✅ Valid .where("age", "contains", "18") // ❌ Error: contains not valid for number // Boolean fields .where("isActive", "equals", true) // ✅ Valid .where("isActive", "contains", true); // ❌ Error: contains not valid for boolean ``` ### Value Types Values are validated to match the field type: ```typescript theme={null} const criteria = Criteria.create() .whereEquals("age", 25) // ✅ Valid: number for number field .whereEquals("age", "25") // ❌ Error: string for number field .whereEquals("name", "John") // ✅ Valid: string for string field .whereIn("age", [20, 25, 30]); // ✅ Valid: number[] for number field ``` ## Serialization ### To JSON ```typescript theme={null} const criteria = Criteria.create() .whereEquals("status", "active") .orderByDesc("createdAt") .paginate(1, 20); const json = criteria.toJSON(); // { // filters: [{ field: "status", operator: "equals", value: "active" }], // orders: [{ field: "createdAt", direction: "desc" }], // pagination: { page: 1, limit: 20, offset: 0 }, // search: undefined // } ``` ### From Object ```typescript theme={null} const criteria = Criteria.fromObject({ filters: [ { field: "status", operator: "equals", value: "active" }, { field: "age", operator: "greaterThan", value: 18 }, ], orders: [{ field: "createdAt", direction: "desc" }], pagination: { page: 1, limit: 20, offset: 0 }, }); ``` ### From Query Params Perfect for REST APIs: ```typescript theme={null} const criteria = Criteria.fromQueryParams({ filters: { "status:equals": "active", "age:greaterThan": "18", }, orderBy: ["createdAt:desc"], pagination: { page: 1, limit: 20 }, }); ``` ## Cloning Create independent copies for variations: ```typescript theme={null} const baseCriteria = Criteria.create() .whereEquals("status", "active") .orderByDesc("createdAt"); // Clone and add more filters const adminCriteria = baseCriteria.clone().whereEquals("role", "admin"); const recentCriteria = baseCriteria .clone() .where("createdAt", "greaterThan", lastWeek); ``` ## Checking State ```typescript theme={null} const criteria = Criteria.create().whereEquals("status", "active"); criteria.hasFilters(); // true criteria.hasOrders(); // false criteria.hasPagination(); // true (default pagination) criteria.hasSearch(); // false ``` ## Getting Components ```typescript theme={null} const criteria = Criteria.create() .whereEquals("status", "active") .where("age", "greaterThan", 18) .orderByDesc("createdAt") .paginate(2, 10); // Get filters const filters = criteria.getFilters(); // [ // { field: "status", operator: "equals", value: "active" }, // { field: "age", operator: "greaterThan", value: 18 } // ] // Get orders const orders = criteria.getOrders(); // [{ field: "createdAt", direction: "desc" }] // Get pagination const pagination = criteria.getPagination(); // { page: 2, limit: 10, offset: 10 } // Get search const search = criteria.getSearch(); // undefined or string ``` ## Integration with Repository ```typescript theme={null} class UserRepository extends Repository { async findActive( page: number, limit: number ): Promise> { const criteria = Criteria.create() .whereEquals("status", "active") .whereNotNull("email") .orderByDesc("createdAt") .paginate(page, limit); return this.find(criteria); } async search(query: string): Promise> { const criteria = Criteria.create() .search(query) .limit(50); return this.find(criteria); } } ``` ## Next Steps Complete guide to all filter operators and their usage Learn about sorting, pagination, and search API integration, field adapters, and quantifiers Working with paginated query results # Drizzle Source: https://woltz.mintlify.app/integrations/drizzle Integration between rich-domain and Drizzle ORM ## Overview `@woltz/rich-domain-drizzle` integrates rich-domain with Drizzle ORM. Unlike Prisma, Drizzle works at the SQL level, which means you have full control over queries but must configure things explicitly — junction tables, column mappings, and eager-loaded relations are all opt-in. ```bash theme={null} npm install @woltz/rich-domain @woltz/rich-domain-drizzle drizzle-orm ``` Request-isolated transactions via AsyncLocalStorage DrizzleRepository with built-in Criteria support DrizzleToPersistence with automatic change detection DrizzleBatchExecutor for efficient bulk writes Optional `outboxStore` in repository config for guaranteed event delivery For a complete working example, see the [fastify-with-drizzle example](https://github.com/tarcisioandrade/rich-domain/tree/main/examples/backend/fastify-with-drizzle) in the repository. *** ## DrizzleUnitOfWork Manages transactions with per-request isolation using `AsyncLocalStorage`. ```typescript theme={null} import { drizzle } from "drizzle-orm/node-postgres"; import { Pool } from "pg"; import { DrizzleUnitOfWork } from "@woltz/rich-domain-drizzle"; import * as schema from "./schema"; const pool = new Pool({ connectionString: process.env.DATABASE_URL }); const db = drizzle(pool, { schema }); export const uow = new DrizzleUnitOfWork(db); ``` ### Transaction Execution ```typescript theme={null} await uow.transaction(async () => { await userRepository.save(user); await postRepository.save(post); // All or nothing — auto rollback on failure }); ``` ### Nested Transactions (Idempotent) If `transaction()` is called inside an already-active transaction, it reuses the same context instead of nesting: ```typescript theme={null} await uow.transaction(async () => { // outer transaction starts await uow.transaction(async () => { // reuses the outer context — no nested transaction await userRepository.save(user); }); }); // single commit ``` ### API Reference | Method | Returns | Description | | --------------------- | ----------------------------------- | ----------------------------------- | | `transaction(work)` | `Promise` | Execute work in a transaction | | `isInTransaction()` | `boolean` | Check if currently in a transaction | | `getCurrentContext()` | `DrizzleTransactionContext \| null` | Get current transaction context | *** ## @Transactional Decorator Wraps a method in a transaction automatically. Reuses the existing transaction if one is already active. ```typescript theme={null} import { Transactional } from "@woltz/rich-domain-drizzle"; class CreateUserUseCase { constructor( private readonly userRepository: UserRepository, private readonly uow: DrizzleUnitOfWork ) {} @Transactional() async execute(input: CreateUserInput): Promise { const user = User.create(input); await this.userRepository.save(user); return user; } } ``` ### With Explicit UoW Parameter ```typescript theme={null} @Transactional(myUnitOfWork) async execute(input: CreateUserInput): Promise { ... } ``` ### UoW Resolution Order The decorator looks for the UoW instance in this order: 1. **Decorator parameter** — `@Transactional(myUow)` 2. **`this.uow`** property 3. **`this._uow`** property 4. **Any property** that is a `DrizzleUnitOfWork` instance ### Behavior | Scenario | Behavior | | ---------------------- | ----------------------- | | Direct call | Creates new transaction | | Already in transaction | Reuses existing one | | Error thrown | Automatic rollback | *** ## DrizzleRepository Base class for repositories. Provides `find`, `findById`, `findManyByIds`, `save`, `delete`, and more with full Criteria support. ```typescript theme={null} import { DrizzleRepository } from "@woltz/rich-domain-drizzle"; abstract class DrizzleRepository< TDomain, TPersistence, TDb extends DrizzleClient = DrizzleClient, > { constructor(config: DrizzleRepositoryConfig) { ... } // Available: current context (transaction or plain db) — typed as TDb protected get context(): TDb; // Required: Drizzle query model name (key in db.query) protected abstract get model(): string; // Required: fields used when criteria.search() is called protected abstract getSearchableFields(): SearchableField[]; // Optional: relations to include via Drizzle relational query API protected getDefaultRelations(): Record { return {}; } // Built-in methods async find(criteria: Criteria): Promise>; async findById(id: string): Promise; async findManyByIds(ids: string[]): Promise; async count(criteria?: Criteria): Promise; async exists(id: string): Promise; async save(entity: TDomain): Promise; async delete(entity: TDomain): Promise; async deleteById(id: string): Promise; async transaction(work: () => Promise): Promise; } ``` ### Transactional Outbox Pass an optional **`outboxStore`** in `DrizzleRepositoryConfig`. When set, `save()` automatically persists uncommitted domain events to the outbox table in the **same database transaction** as the aggregate write. | Property | Type | Required | Description | | ------------- | -------------------- | -------- | ---------------------------------------------- | | `outboxStore` | `DrizzleOutboxStore` | No | Enables auto-save of domain events on `save()` | Use `DrizzleOutboxStore` from `@woltz/rich-domain-drizzle`. For the full setup (schema, event bus decorator, background publisher), see **[Transactional Outbox](/integrations/outbox)**. ```typescript theme={null} import { DrizzleOutboxStore } from "@woltz/rich-domain-drizzle"; const outboxStore = new DrizzleOutboxStore(db); const repo = new OrderRepository({ db, table: schema.orders, toDomainMapper: new OrderToDomainMapper(), toPersistenceMapper: new OrderToPersistenceMapper(db, uow), uow, outboxStore, // ← events saved atomically with the aggregate }); ``` ### Complete Implementation ```typescript theme={null} import { DrizzleRepository, DrizzleOutboxStore, SearchableField, } from "@woltz/rich-domain-drizzle"; import { users, UserRecord } from "../schema"; type DB = ReturnType; export class UserRepository extends DrizzleRepository { constructor(db: DB, uow: DrizzleUnitOfWork, outboxStore?: DrizzleOutboxStore) { super({ db, table: users, toDomainMapper: new UserToDomainMapper(), toPersistenceMapper: new UserToPersistenceMapper(db, uow), uow, outboxStore, }); } protected get model() { return "users"; // key in db.query — must match Drizzle schema export name } protected getSearchableFields(): SearchableField[] { return ["name", "email"]; } protected getDefaultRelations() { return { posts: { with: { tags: { with: { tag: true } } }, }, }; } async findByEmail(email: string): Promise { const record = await this.context.query.users.findFirst({ where: eq(users.email, email), with: this.getDefaultRelations(), }); if (!record) return null; const user = this.toDomainMapper.build(record as any); user.markAsClean(); return user; } } ``` ### Context-Aware Queries Use `this.context` in custom methods — it automatically switches to the transaction client when inside a transaction: ```typescript theme={null} async findActiveUsers(): Promise { const records = await this.context.query.users.findMany({ where: eq(users.status, "active"), with: this.getDefaultRelations(), }); return records.map((r) => this.toDomainMapper.build(r)); } ``` *** ## EntitySchemaRegistry Maps domain entities to Drizzle tables, configures FK relationships, and describes collection types. See the complete [Schema Registry documentation](/repository/schema-registry) for all features. ### Basic Registration ```typescript theme={null} import { EntitySchemaRegistry } from "@woltz/rich-domain"; const registry = new EntitySchemaRegistry() .register({ entity: "User", table: "users", }) .register({ entity: "Post", table: "posts", parentFk: { field: "authorId", // FK column in DB parentEntity: "User", // name of the parent entity }, }); ``` ### Owned Collections (1:N) ```typescript theme={null} const registry = new EntitySchemaRegistry() .register({ entity: "User", table: "users", collections: { posts: { type: "owned", // Posts are lifecycle-managed by User entity: "Post", }, }, }) .register({ entity: "Post", table: "posts", parentFk: { field: "authorId", parentEntity: "User", }, }); ``` ### Reference Collections (N:N) — Junction Required Unlike Prisma, Drizzle **always requires** an explicit `junction` config for `reference` collections. Drizzle does not manage junction tables automatically. Omitting `junction` will throw a `MissingJunctionConfigError` at runtime. ```typescript theme={null} const registry = new EntitySchemaRegistry() .register({ entity: "Post", table: "posts", collections: { tags: { type: "reference", // Tags exist independently; only the link is managed entity: "Tag", junction: { table: "posts_to_tags", // must match your tableMap key sourceKey: "postId", // FK column pointing to Post targetKey: "tagId", // FK column pointing to Tag }, }, }, }) .register({ entity: "Tag", table: "tags" }); ``` ### Collection Types | Type | Relationship | Creates | Deletes | | ----------- | ------------ | ---------------------------- | ---------------------------- | | `owned` | 1:N | `INSERT` into child table | `DELETE` from child table | | `reference` | N:N | `INSERT` into junction table | `DELETE` from junction table | ### tableMap The `tableMap` maps entity names (and junction table names) to the actual Drizzle table objects. It is used by `DrizzleBatchExecutor` to execute queries. ```typescript theme={null} protected readonly tableMap = new Map([ ["User", users], // entity name → Drizzle table ["Post", posts], ["Tag", tags], ["posts_to_tags", postsToTags], // junction table name → Drizzle table ]); ``` The keys in `tableMap` must exactly match the entity names used in `registry.register({ entity: "..." })` and the junction table names in `junction.table`. A wrong key throws `TableNotFoundError` with a list of available keys. *** ## DrizzleToPersistence Base mapper class for persisting aggregates. You control `onCreate` manually; `onUpdate` defaults to `DrizzleBatchExecutor`. ```typescript theme={null} import { DrizzleToPersistence, Transactional } from "@woltz/rich-domain-drizzle"; import { EntitySchemaRegistry } from "@woltz/rich-domain"; type DB = ReturnType; export class UserToPersistenceMapper extends DrizzleToPersistence { protected readonly registry = new EntitySchemaRegistry() .register({ entity: "User", table: "users", collections: { posts: { type: "owned", entity: "Post" }, }, }) .register({ entity: "Post", table: "posts", parentFk: { field: "authorId", parentEntity: "User" }, collections: { tags: { type: "reference", entity: "Tag", junction: { table: "posts_to_tags", sourceKey: "postId", targetKey: "tagId" }, }, }, }) .register({ entity: "Tag", table: "tags" }); protected readonly tableMap = new Map([ ["User", users], ["Post", posts], ["Tag", tags], ["posts_to_tags", postsToTags], ]); constructor(db: DB, uow: DrizzleUnitOfWork) { super(db, uow); } @Transactional() protected async onCreate(user: User): Promise { // Insert root aggregate await this.context.insert(users).values({ id: user.id.value, email: user.email, name: user.name, createdAt: user.createdAt, updatedAt: user.updatedAt, }); // Insert owned children (Posts) if (user.posts.length > 0) { await this.context.insert(posts).values( user.posts.map((p) => ({ id: p.id.value, title: p.title, content: p.content, published: p.published, authorId: user.id.value, createdAt: p.createdAt, updatedAt: p.updatedAt, })) ); } } // onUpdate is handled automatically by DrizzleBatchExecutor (default implementation). // Override only if you need custom update logic. } ``` ### onUpdate Default Behavior If you do not override `onUpdate`, the adapter uses `DrizzleBatchExecutor.execute(changes)` automatically: ```typescript theme={null} // Default — no override needed for standard change tracking protected async onUpdate(changes: AggregateChanges, entity: User): Promise { const executor = new DrizzleBatchExecutor({ registry: this.registry, db: this.context, tableMap: this.tableMap, }); await executor.execute(changes); } ``` *** ## DrizzleBatchExecutor Executes `AggregateChanges` in the correct order, respecting referential integrity. ```typescript theme={null} import { DrizzleBatchExecutor } from "@woltz/rich-domain-drizzle"; const executor = new DrizzleBatchExecutor({ registry: schemaRegistry, db: context, tableMap, }); await executor.execute(changes); ``` ### Execution Order 1. **Deletes** — Leaf → Root (depth DESC) * `owned`: `DELETE FROM table WHERE id IN (...)` * `reference`: `DELETE FROM junction WHERE sourceKey = ? AND targetKey IN (...)` 2. **Creates** — Root → Leaf (depth ASC) * `owned`: `INSERT INTO table VALUES (...)` * `reference`: `INSERT INTO junction VALUES (...) ON CONFLICT DO NOTHING` 3. **Updates** — Any order *** ## Criteria Support `DrizzleQueryBuilder` translates a `Criteria` instance into Drizzle `where`, `orderBy`, `limit`, and `offset` clauses. ```typescript theme={null} const criteria = Criteria.create() .whereEquals("published", true) .orderByAsc("createdAt") .paginate(1, 20); const result = await userRepository.find(criteria); // result.data → User[] // result.toJSON().meta → { page, limit, total, totalPages, hasNext, hasPrev } ``` ### Supported Operators | Criteria Operator | SQL | | -------------------- | ----------------- | | `equals` | `= ?` | | `notEquals` | `!= ?` | | `greaterThan` | `> ?` | | `greaterThanOrEqual` | `>= ?` | | `lessThan` | `< ?` | | `lessThanOrEqual` | `<= ?` | | `contains` | `ILIKE '%value%'` | | `startsWith` | `ILIKE 'value%'` | | `endsWith` | `ILIKE '%value'` | | `in` | `IN (...)` | | `notIn` | `NOT IN (...)` | | `isNull` | `IS NULL` | | `isNotNull` | `IS NOT NULL` | | `between` | `BETWEEN ? AND ?` | *** ## Limitations ### No Dot-Notation Field Paths Criteria filters, ordering, and search fields must reference **top-level columns on the repository's primary table**. Dot paths like `"profile.name"` or `"posts.title"` are not supported and will throw a `DrizzleAdapterError`. ```typescript theme={null} // ❌ Throws DrizzleAdapterError Criteria.create().whereEquals("profile.name", "John"); Criteria.create().orderByAsc("address.city"); // ✅ Top-level columns only Criteria.create().whereEquals("name", "John"); Criteria.create().orderByAsc("createdAt"); ``` For cross-table filtering or ordering, add a custom method to your repository using Drizzle's SQL API with explicit JOINs: ```typescript theme={null} async findByPostTitle(title: string): Promise { const records = await this.context .select() .from(users) .innerJoin(posts, eq(posts.authorId, users.id)) .where(ilike(posts.title, `%${title}%`)); return records.map(({ users: u }) => this.toDomainMapper.build(u as any)); } ``` ### No Relation Quantifiers Criteria quantifiers (`some`, `every`, `none`) are not supported. Use raw Drizzle queries with `EXISTS` subqueries for these cases. ```typescript theme={null} // ❌ Not supported Criteria.create().where("posts", "some", { published: true }); // ✅ Add a custom repository method async findUsersWithPublishedPosts(): Promise { ... } ``` ### `contains` is Case-Insensitive (PostgreSQL Only) The `contains`, `startsWith`, and `endsWith` operators use `ILIKE`, which is a PostgreSQL-specific operator. They will not work on SQLite or MySQL without customization. ### Junction Config is Always Required Unlike Prisma (which handles implicit N:N automatically), Drizzle requires an explicit `junction` config for every `reference` collection. Omitting it throws `MissingJunctionConfigError` with an example config. *** ## Error Reference | Error | When thrown | | ---------------------------- | ------------------------------------------------------------------ | | `TableNotFoundError` | `tableMap` key not found for an entity or junction name | | `MissingJunctionConfigError` | `reference` collection has no `junction` configured | | `BatchOperationError` | DB error during a batch create, update, or delete | | `NoRecordsAffectedError` | `delete()` / `deleteById()` matched 0 rows | | `DrizzleAdapterError` | Unsupported Criteria operator, dot-field path, or column not found | ```typescript theme={null} import { TableNotFoundError, MissingJunctionConfigError, BatchOperationError, NoRecordsAffectedError, DrizzleAdapterError, } from "@woltz/rich-domain-drizzle"; try { await userRepository.save(user); } catch (error) { if (error instanceof MissingJunctionConfigError) { // reference collection missing junction — config error } else if (error instanceof TableNotFoundError) { // entity not found in tableMap — config error } else if (error instanceof BatchOperationError) { // DB-level failure during batch write } else if (error instanceof NoRecordsAffectedError) { // delete targeted a non-existent row } } ``` *** ## API Reference ### Exports ```typescript theme={null} // Unit of Work export { DrizzleUnitOfWork, Transactional, getCurrentDrizzleContext }; export type { DrizzleClient, DrizzleTransactionClient, DrizzleTransactionContext }; // Repository export { DrizzleRepository }; export type { DrizzleRepositoryConfig }; // Outbox export { DrizzleOutboxStore, outboxTable }; // Mappers export { DrizzleToPersistence }; export { DrizzleToDomain }; // Batch Executor export { DrizzleBatchExecutor, executeBatch }; export type { DrizzleBatchExecutorConfig }; // Query Builder export { DrizzleQueryBuilder }; export type { SearchableField }; // Errors export { DrizzleAdapterError, TableNotFoundError, MissingJunctionConfigError, NoRecordsAffectedError, BatchOperationError, DrizzleRepositoryError, OutboxStoreError, }; ``` ### DrizzleRepositoryConfig ```typescript theme={null} interface DrizzleRepositoryConfig { db: TDb; table: any; toDomainMapper: Mapper; toPersistenceMapper: DrizzleToPersistence; uow: DrizzleUnitOfWork; /** Optional — auto-save domain events on `save()`. See [Transactional Outbox](/integrations/outbox). */ outboxStore?: DrizzleOutboxStore; } ``` ### DrizzleRepository Methods | Method | Returns | Description | | -------------------- | ----------------------------- | ----------------------------------------- | | `find(criteria)` | `Promise>` | Find with filters, ordering, pagination | | `findById(id)` | `Promise` | Find single entity by ID | | `findManyByIds(ids)` | `Promise` | Find multiple entities by IDs | | `count(criteria?)` | `Promise` | Count matching entities | | `exists(id)` | `Promise` | Check if entity exists | | `save(entity)` | `Promise` | Create or update (detected automatically) | | `delete(entity)` | `Promise` | Delete by entity instance | | `deleteById(id)` | `Promise` | Delete by ID string | | `transaction(work)` | `Promise` | Execute work in a transaction | ### DrizzleUnitOfWork Methods | Method | Returns | Description | | --------------------- | ----------------------------------- | ------------------------------- | | `transaction(work)` | `Promise` | Execute work in a transaction | | `isInTransaction()` | `boolean` | Check if in a transaction | | `getCurrentContext()` | `DrizzleTransactionContext \| null` | Get current transaction context | # Multi-Format Export Source: https://woltz.mintlify.app/integrations/export Export your domain entities to CSV, JSON, JSONL and custom formats with type-safe columns, transformers, and streaming support ## Overview **@woltz/rich-domain-export** provides powerful multi-format export capabilities for your rich-domain entities. Export data to CSV, JSON, JSON Lines (JSONL), and custom formats with full type safety, custom formatting, and efficient streaming for large datasets. CSV, JSON, JSONL, and extensible for custom formats Full TypeScript support with discriminated unions Memory-efficient export for large datasets Real-time progress callbacks for long-running exports ## Installation ```bash theme={null} npm install @woltz/rich-domain-export ``` This is a **backend-only** package (Node.js). For frontend exports, use API endpoints. ## Quick Start ### Approach 1: Repository Extension Extend your repository with export capabilities: ```typescript theme={null} import { ExportableRepository } from "@woltz/rich-domain-export"; import { Criteria } from "@woltz/rich-domain"; class UserRepository extends ExportableRepository { // Your repository implementation } const userRepository = new UserRepository(); // Export as CSV const { data, stats } = await userRepository.export( Criteria.create().where("status", "equals", "active"), { format: "csv", columns: ["name", "email", "createdAt"], headers: { name: "Full Name", email: "Email Address", createdAt: "Registration Date" } } ); console.log(`Exported ${stats.totalRecords} records in ${stats.durationMs}ms`); ``` ### Approach 2: Composition with ExportService Use the standalone service to keep export logic separate: ```typescript theme={null} import { ExportService } from "@woltz/rich-domain-export"; const exportService = new ExportService(); // Export from any repository const { data, stats } = await exportService.export( userRepository, criteria, { format: "csv", columns: ["name", "email"] } ); ``` ## Supported Formats ### CSV Format Export entities to comma-separated values format: ```typescript theme={null} const { data, stats } = await repository.export(criteria, { format: "csv", columns: ["name", "email", "age"], headers: { name: "Full Name", email: "Email Address", age: "Age" }, delimiter: ",", includeHeaders: true, formatters: { age: (value) => `${value} years old` } }); // Output: // Full Name,Email Address,Age // John Doe,john@example.com,25 years old // Jane Smith,jane@example.com,30 years old ``` **CSV Options:** | Option | Type | Default | Description | | ----------------- | -------------------------- | ---------- | ---------------------------------- | | `format` | `"csv"` | - | Format discriminator (required) | | `columns?` | `string[]` | all fields | Fields to include | | `headers?` | `Record` | - | Custom header labels | | `delimiter?` | `string` | `","` | Delimiter character | | `includeHeaders?` | `boolean` | `true` | Include header row | | `formatters?` | `Record` | - | Custom formatters (returns string) | | `batchSize?` | `number` | `1000` | Batch size for streaming | ### JSON Format Export entities to standard JSON array: ```typescript theme={null} const { data, stats } = await repository.export(criteria, { format: "json", pretty: true, indent: 2, fields: ["name", "email"], rootKey: "users", transformers: { email: (email) => email.toLowerCase() } }); // Output: // { // "users": [ // { "name": "John Doe", "email": "john@example.com" }, // { "name": "Jane Smith", "email": "jane@example.com" } // ] // } ``` **JSON Options:** | Option | Type | Default | Description | | --------------- | -------------------------- | ---------- | -------------------------------------- | | `format` | `"json"` | - | Format discriminator (required) | | `pretty?` | `boolean` | `false` | Pretty print with indentation | | `indent?` | `number` | `2` | Number of spaces for indentation | | `jsonLines?` | `boolean` | `false` | Use JSON Lines format | | `fields?` | `string[]` | all fields | Fields to include | | `transformers?` | `Record` | - | Custom transformers (returns any type) | | `rootKey?` | `string` | - | Wrap output in root key | | `batchSize?` | `number` | `1000` | Batch size for streaming | ### JSON Lines (JSONL) Format Export entities to newline-delimited JSON (streaming-friendly): ```typescript theme={null} const { data, stats } = await repository.export(criteria, { format: "json", jsonLines: true, fields: ["name", "email"] }); // Output (each line is a valid JSON object): // {"name":"John Doe","email":"john@example.com"} // {"name":"Jane Smith","email":"jane@example.com"} ``` JSON Lines is ideal for streaming large datasets and is supported by many data processing tools. Learn more at [jsonlines.org](https://jsonlines.org/). ## Common Use Cases ### Export with Filters Use Criteria to filter data before export: ```typescript theme={null} import { Criteria } from "@woltz/rich-domain"; const criteria = Criteria.create() .where("status", "equals", "active") .where("age", "greaterThan", 18) .orderBy("name", "asc"); const { data } = await repository.export(criteria, { format: "csv", columns: ["name", "email", "age"] }); ``` ### Custom Formatters (CSV) Transform field values before CSV serialization: ```typescript theme={null} const { data } = await repository.export(criteria, { format: "csv", columns: ["name", "createdAt", "balance", "status"], formatters: { createdAt: (date) => new Date(date).toLocaleDateString("en-US"), balance: (value) => `$${value.toFixed(2)}`, status: (value) => value.toUpperCase() } }); ``` ### Custom Transformers (JSON) Transform field values with any type (not just strings): ```typescript theme={null} const { data } = await repository.export(criteria, { format: "json", fields: ["name", "tags", "metadata", "amount"], transformers: { tags: (tags) => tags.join(", "), metadata: (meta) => ({ ...meta, exported: true }), amount: (num) => Number(num.toFixed(2)) } }); ``` ### Common Formatters The library provides pre-built formatters for CSV: ```typescript theme={null} import { commonFormatters } from "@woltz/rich-domain-export"; const { data } = await repository.export(criteria, { format: "csv", columns: ["name", "amount", "createdAt", "active", "tags"], formatters: { amount: commonFormatters.currencyUSD, createdAt: commonFormatters.isoDate, active: commonFormatters.yesNo, tags: commonFormatters.array } }); ``` **Available formatters:** * `isoDate` - ISO 8601 date string * `localeDate` - Locale date string (e.g., "1/1/2024") * `localeDateTime` - Locale datetime string * `decimal2` - Number with 2 decimal places * `currencyUSD` - USD currency format (\$X.XX) * `yesNo` - Boolean as "Yes" or "No" * `trueFalse` - Boolean as "True" or "False" * `array` - Array as comma-separated string * `json` - Object as JSON string * `uppercase` - Convert to uppercase * `lowercase` - Convert to lowercase * `trim` - Trim whitespace ### Progress Tracking Monitor export progress for large datasets: ```typescript theme={null} const { data, stats } = await repository.export( criteria, { format: "csv", columns: ["name", "email"] }, (processed, total) => { const percentage = (processed / total) * 100; console.log(`Export progress: ${percentage.toFixed(1)}%`); } ); ``` ## Streaming for Large Datasets For large datasets, use streaming to avoid loading everything into memory: ### CSV Stream ```typescript theme={null} import * as fs from "fs"; const stream = await repository.exportStream(criteria, { format: "csv", columns: ["name", "email"], batchSize: 1000 // Process 1000 records at a time }); stream.pipe(fs.createWriteStream("users.csv")); ``` ### JSON Lines Stream (Recommended for Large JSON Exports) ```typescript theme={null} const stream = await repository.exportStream(criteria, { format: "json", jsonLines: true, batchSize: 500 }); stream.pipe(fs.createWriteStream("users.jsonl")); ``` ### HTTP Streaming (Fastify Example) ```typescript theme={null} // CSV download const stream = await repository.exportStream(criteria, { format: "csv", columns: ["name", "email"] }); reply .header("Content-Type", "text/csv") .header("Content-Disposition", 'attachment; filename="users.csv"') .send(stream); // JSON Lines download const stream = await repository.exportStream(criteria, { format: "json", jsonLines: true }); reply .header("Content-Type", "application/x-ndjson") .header("Content-Disposition", 'attachment; filename="users.jsonl"') .send(stream); ``` ### HTTP Streaming (Express Example) ```typescript theme={null} const stream = await repository.exportStream(criteria, { format: "json", pretty: true }); res.setHeader("Content-Type", "application/json"); res.setHeader("Content-Disposition", 'attachment; filename="users.json"'); stream.pipe(res); ``` ## Performance Considerations | Dataset Size | Recommended Method | Memory Usage | | ---------------- | ------------------ | --------------------- | | \< 1,000 records | `export()` | \~1-5 MB | | 1,000 - 10,000 | `export()` | \~5-50 MB | | 10,000 - 100,000 | `exportStream()` | \~10-20 MB (constant) | | > 100,000 | `exportStream()` | \~10-20 MB (constant) | **Tips:** * Use `exportStream()` for datasets > 10,000 records * Use JSON Lines (`jsonLines: true`) for streaming large JSON exports * Adjust `batchSize` option to control memory usage (default: 1000) ## Custom Formats Extend the library with custom formats using the Strategy Pattern: ```typescript theme={null} import { ExportFormatStrategy, FormatRegistry, ExportResult, ValidationResult } from "@woltz/rich-domain-export"; import type { Readable } from "stream"; // Define custom options interface ExcelExportOptions extends BaseExportOptions { format: "excel"; sheetName?: string; columns?: string[]; } // Implement the strategy class ExcelFormatStrategy implements ExportFormatStrategy { async export(records: any[], options: ExcelExportOptions): Promise { // Your Excel export logic const data = this.generateExcel(records, options); return { data, stats: { totalRecords: records.length, sizeInBytes: data.length, durationMs: 0, // ... other stats } }; } async exportStream( recordsIterator: AsyncIterable, options: ExcelExportOptions ): Promise { // Your streaming logic const { Readable } = await import("stream"); return Readable.from(this.streamExcel(recordsIterator, options)); } validateOptions(options: ExcelExportOptions): ValidationResult { const errors: string[] = []; if (options.sheetName && options.sheetName.length > 31) { errors.push("Sheet name must be 31 characters or less"); } return { isValid: errors.length === 0, errors }; } getMimeType(): string { return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; } getFileExtension(): string { return "xlsx"; } getFormatName(): string { return "excel"; } private generateExcel(records: any[], options: ExcelExportOptions): string { // Implementation here return ""; } private async *streamExcel( recordsIterator: AsyncIterable, options: ExcelExportOptions ): AsyncGenerator { // Implementation here } } // Register custom format FormatRegistry.register("excel", new ExcelFormatStrategy()); // Use it const { data } = await repository.export(criteria, { format: "excel", sheetName: "Users", columns: ["name", "email"] }); ``` ## Export Statistics Get detailed statistics about the export operation: ```typescript theme={null} const { data, stats } = await repository.export(criteria, { format: "csv", columns: ["name", "email"] }); console.log({ totalRecords: stats.totalRecords, // Number of records exported sizeInBytes: stats.sizeInBytes, // Data size in bytes durationMs: stats.durationMs, // Export duration }); // CSV-specific stats if (stats.totalColumns) { console.log(`Exported ${stats.totalColumns} columns`); } ``` ## Type Safety The library provides full type safety with discriminated unions: ```typescript theme={null} // TypeScript enforces valid options for each format const csvExport = await repository.export(criteria, { format: "csv", columns: ["name"], // ✓ Valid for CSV delimiter: "," // ✓ Valid for CSV // pretty: true // ✗ Error: 'pretty' doesn't exist on CSV options }); const jsonExport = await repository.export(criteria, { format: "json", pretty: true, // ✓ Valid for JSON fields: ["name"] // ✓ Valid for JSON // delimiter: "," // ✗ Error: 'delimiter' doesn't exist on JSON options }); ``` ## Error Handling The library provides specific error types: ```typescript theme={null} import { ValidationError, FormatterError, ExportOperationError } from "@woltz/rich-domain-export"; try { const { data } = await repository.export(criteria, { format: "csv", columns: ["name", "email"] }); } catch (error) { if (error instanceof ValidationError) { console.error("Invalid options:", error.validationErrors); } else if (error instanceof FormatterError) { console.error(`Formatter failed for field: ${error.field}`); } else if (error instanceof ExportOperationError) { console.error(`Export failed at phase: ${error.phase}`); } } ``` ## Complete Example ```typescript theme={null} import { ExportableRepository, commonFormatters } from "@woltz/rich-domain-export"; import { Criteria } from "@woltz/rich-domain"; class UserRepository extends ExportableRepository { // Your repository implementation } async function exportActiveUsers() { const userRepository = new UserRepository(); // Create criteria for active users over 18 const criteria = Criteria.create() .where("status", "equals", "active") .where("age", "greaterThanOrEqual", 18) .orderBy("name", "asc"); // Export as CSV with custom formatting const { data: csv, stats: csvStats } = await userRepository.export( criteria, { format: "csv", columns: ["name", "email", "age", "createdAt", "balance"], headers: { name: "Full Name", email: "Email Address", age: "Age", createdAt: "Registered", balance: "Account Balance" }, formatters: { createdAt: commonFormatters.localeDate, balance: commonFormatters.currencyUSD } }, (processed, total) => { const percentage = Math.round((processed / total) * 100); console.log(`CSV Export: ${percentage}%`); } ); console.log(`✅ CSV: ${csvStats.totalRecords} users in ${csvStats.durationMs}ms`); // Export as JSON const { data: json, stats: jsonStats } = await userRepository.export( criteria, { format: "json", pretty: true, fields: ["name", "email", "age"], rootKey: "users", transformers: { email: (email) => email.toLowerCase() } } ); console.log(`✅ JSON: ${jsonStats.totalRecords} users in ${jsonStats.durationMs}ms`); // Export as JSON Lines for streaming const stream = await userRepository.exportStream(criteria, { format: "json", jsonLines: true, batchSize: 500 }); stream.pipe(fs.createWriteStream("users.jsonl")); } ``` ## API Reference ### ExportableRepository ```typescript theme={null} abstract class ExportableRepository extends Repository { export( criteria?: Criteria, options: ExportOptions, onProgress?: ExportProgressCallback ): Promise; exportStream( criteria?: Criteria, options: ExportOptions ): Promise; } ``` ### ExportService ```typescript theme={null} class ExportService { export( repository: Repository, criteria: Criteria | undefined, options: ExportOptions, onProgress?: ExportProgressCallback ): Promise; exportStream( repository: Repository, criteria: Criteria | undefined, options: ExportOptions ): Promise; getMimeType(format: string): string; getFileExtension(format: string): string; } ``` ### FormatRegistry ```typescript theme={null} class FormatRegistry { static register( format: string, strategyInstance: ExportFormatStrategy ): void; static getStrategy(format: string): ExportFormatStrategy; static hasFormat(format: string): boolean; static getRegisteredFormats(): string[]; } ``` ### ExportOptions ```typescript theme={null} type ExportOptions = CsvExportOptions | JsonExportOptions; interface CsvExportOptions extends BaseExportOptions { format: "csv"; columns?: PropsOf[]; headers?: Partial, string>>; delimiter?: string; includeHeaders?: boolean; formatters?: Partial, (value: any) => string>>; } interface JsonExportOptions extends BaseExportOptions { format: "json"; pretty?: boolean; indent?: number; jsonLines?: boolean; fields?: PropsOf[]; transformers?: Partial, (value: any) => any>>; rootKey?: string; } interface BaseExportOptions { batchSize?: number; // Default: 1000 } ``` ### ExportResult ```typescript theme={null} interface ExportResult { data: string; stats: BaseExportStats | CsvExportStats | JsonExportStats; } interface BaseExportStats { totalRecords: number; sizeInBytes: number; durationMs: number; } interface CsvExportStats extends BaseExportStats { totalColumns: number; } interface JsonExportStats extends BaseExportStats { // JSON-specific stats can be added here } ``` *** # Transactional Outbox Source: https://woltz.mintlify.app/integrations/outbox Guaranteed domain event delivery with the Transactional Outbox pattern ## The Problem 💀 Consider a typical e-commerce flow: ```typescript theme={null} // User places an order const order = Order.create({ items, total: 150 }); await repo.save(order); // ✅ Persisted to DB // Dispatch domain events await order.dispatchAll(bus); // 💀 CRASH HERE ``` ### The Failure Window There's a **gap** between `save()` and `dispatchAll()`. If the process dies in that gap: | What happens | Impact | | --------------------- | ------------------- | | Order is saved to DB | ✅ Data is safe | | `OrderPlaced` event | ❌ **Silently lost** | | Inventory reservation | ❌ Never happens | | Confirmation email | ❌ Never sent | | Analytics tracking | ❌ Missing | This isn't theoretical. It happens in production: * **Process crashes** (unhandled exceptions, OOM kills) * **Deployments** (container is replaced mid-request) * **Network blips** (message broker unreachable at that exact moment) * **Infrastructure** (node restart, pod eviction) The event is **silently lost** — no trace, no retry, no recovery. ## The Solution — Transactional Outbox Pattern The **Transactional Outbox Pattern** ensures events are persisted **atomically** with your aggregate write, then published by a background process: ``` ┌──────────────────────────────────────────────────┐ │ Transaction Boundary │ │ │ │ INSERT INTO orders (...) │ │ INSERT INTO outbox (eventId, payload, ...) │ │ │ │ ═══════════ COMMIT ═══════════ │ │ ✅ Both rows written atomically │ └──────────────────────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────┐ │ OutboxPublisher (background process) │ │ │ │ SELECT * FROM outbox WHERE status = 'pending' │ │ → publish to message broker │ │ → UPDATE outbox SET status = 'published' │ │ │ │ On failure: │ │ → UPDATE outbox SET status = 'failed', retries++│ │ → Retry on next poll │ └──────────────────────────────────────────────────┘ ``` ### Immediate-First, Outbox as Safety Net Rich Domain's implementation uses a **decorator pattern** — your code stays identical: ```typescript theme={null} // Your use case code — unchanged await repo.save(order); await order.dispatchAll(bus); // Still publishes immediately! ``` Behind the scenes: 1. **Immediate publish** — `dispatchAll()` tries the real bus first (RabbitMQ, Kafka, etc.) 2. **Success** → `outboxStore.markPublished(eventId)` — marks as done 3. **Failure** → `outboxStore.markFailed(eventId, error)` — records the error 4. **Background safety net** — `OutboxPublisher` polls for pending events and retries The outbox is the **safety net**, not the primary path. You get low-latency delivery when the broker is healthy, and guaranteed delivery when it's not. The `repo.save()` call also auto-saves uncommitted events to the outbox table in the same transaction — so even if `dispatchAll()` isn't called at all, your events are preserved. ## Proven Pattern This isn't new. Established frameworks have used this pattern for years: | Framework | Ecosystem | How they do it | | ------------------ | --------- | -------------------------------------------------------------------------------------------------------- | | **MassTransit** | .NET | Transactional outbox built-in; stores messages in DB transaction, publishes via Quartz.NET scheduled job | | **NServiceBus** | .NET | Outbox feature since v6; stores outgoing messages in same DB transaction as business data | | **CAP** | .NET | EQueue outbox pattern; `[CapSubscribe]` attribute + transactional message storage | | **Debezium** | Java | CDC-based outbox; tails the DB transaction log to pick up outbox events | | **Axon Framework** | Java | Event sourcing + outbox; persists domain events in the same transaction as the aggregate | Frameworks like [Dapper](https://github.com/DapperLib/Dapper) (.NET) and [Spring Cloud Stream](https://spring.io/projects/spring-cloud-stream) (Java) also support variations of this pattern. `@woltz/rich-domain-outbox` brings the same reliability guarantee to the **TypeScript/Node.js** ecosystem — with zero external dependencies beyond your existing database. *** ## Quick Start ### Step 1: Install ```bash theme={null} npm install @woltz/rich-domain-outbox ``` ### Step 2: Create the Outbox Table The outbox table has a simple, fixed schema. The `id` column stores the event's own `eventId` — this is how `markPublished(eventId)` does a direct primary key lookup: | Column | Type | Description | | ------------ | ---------------- | ------------------------------------------------ | | `id` | `TEXT` | **PRIMARY KEY**. Stores `event.eventId` directly | | `eventName` | `TEXT` | The event class name (e.g. `"OrderPlaced"`) | | `payload` | `JSONB` / `JSON` | The event payload | | `occurredOn` | `TIMESTAMPTZ` | When the event was created | | `status` | `TEXT` | `'pending'` → `'published'` → (or `'failed'`) | | `retries` | `INTEGER` | Number of publish attempts. Default `0` | | `lastError` | `TEXT NULL` | Last error message if publish failed | | `createdAt` | `TIMESTAMPTZ` | When the row was inserted | Pick your ORM: Copy this into your `schema.prisma`: ```prisma theme={null} model Outbox { id String @id eventName String payload Json occurredOn DateTime status String @default("pending") retries Int @default(0) lastError String? createdAt DateTime @default(now()) @@index([status]) } ``` Then import the schema constant for reference: ```typescript theme={null} import { PRISMA_OUTBOX_SCHEMA } from "@woltz/rich-domain-prisma"; ``` Import the table definition from `@woltz/rich-domain-drizzle`: ```typescript theme={null} import { outboxTable } from "@woltz/rich-domain-drizzle"; // Add to your schema export const schema = { users: usersTable, posts: postsTable, outbox: outboxTable, }; ``` Add `OutboxEntity` to your DataSource `entities` array: ```typescript theme={null} import { OutboxEntity } from "@woltz/rich-domain-typeorm"; const dataSource = new DataSource({ // ... entities: [User, Post, OutboxEntity], }); ``` Use the exported DDL: ```typescript theme={null} import { OUTBOX_DDL } from "@woltz/rich-domain-outbox"; // PostgreSQL await db.execute(OUTBOX_DDL.postgresql); // MySQL await db.execute(OUTBOX_DDL.mysql); ``` Then run your migration as usual (`prisma migrate dev`, `drizzle-kit generate`, `typeorm migration:run`, etc.). ### Step 3: Wrap your EventBus ```typescript theme={null} import { OutboxEventBusDecorator } from "@woltz/rich-domain-outbox"; // Your real event bus (RabbitMQ, Kafka, InMemory, etc.) const realBus = new RabbitMQEventBus(connection); // Wrap it with the outbox decorator const bus = new OutboxEventBusDecorator(realBus, outboxStore); ``` ### Step 4: Start the OutboxPublisher ```typescript theme={null} import { OutboxPublisher } from "@woltz/rich-domain-outbox"; const publisher = new OutboxPublisher(outboxStore, realBus, { pollIntervalMs: 5000, // Poll every 5 seconds batchSize: 50, // Process up to 50 events per batch maxRetries: 3, // Give up after 3 failed attempts }); publisher.start(); // Graceful shutdown process.on("SIGTERM", async () => { await publisher.stop(); }); ``` ### Step 5: Your Code Stays the Same ```typescript theme={null} // This code doesn't change at all const order = Order.create({ items, total: 150 }); await repo.save(order); // Events auto-saved to outbox await order.dispatchAll(bus); // Still publishes immediately ``` The outbox is the **safety net**, not a replacement. Events are published immediately when possible. The background publisher only picks up events that failed to publish (or were never dispatched at all). *** ## ORM Integration Each ORM adapter provides an outbox store that integrates with the adapter's transaction management. ### Prisma ```typescript theme={null} import { PrismaClient } from "@prisma/client"; import { PrismaUnitOfWork, PrismaOutboxStore } from "@woltz/rich-domain-prisma"; import { OutboxEventBusDecorator, OutboxPublisher } from "@woltz/rich-domain-outbox"; const prisma = new PrismaClient(); const uow = new PrismaUnitOfWork(prisma); const outboxStore = new PrismaOutboxStore(prisma); // Auto-save in repository class OrderRepository extends PrismaRepository { constructor(prisma: PrismaClient, uow: PrismaUnitOfWork) { super( new OrderToPersistenceMapper(prisma, uow), new OrderToDomainMapper(), prisma, uow, outboxStore // ← pass the outbox store ); } } // Wrap the event bus const bus = new OutboxEventBusDecorator(rabbitBus, outboxStore); // Background publisher const publisher = new OutboxPublisher(outboxStore, rabbitBus, { pollIntervalMs: 5000, }); publisher.start(); ``` When you call `repo.save(order)` inside a `uow.transaction()`, the outbox events are written in the **same database transaction** as the aggregate — guaranteeing atomicity. ### Drizzle ```typescript theme={null} import { drizzle } from "drizzle-orm/node-postgres"; import { DrizzleUnitOfWork, DrizzleOutboxStore } from "@woltz/rich-domain-drizzle"; import { OutboxEventBusDecorator, OutboxPublisher } from "@woltz/rich-domain-outbox"; const db = drizzle(pool, { schema }); const uow = new DrizzleUnitOfWork(db); const outboxStore = new DrizzleOutboxStore(db); // Configure repository with outboxStore in DrizzleRepositoryConfig const repo = new OrderRepository({ db, table: schema.orders, toDomainMapper: new OrderToDomainMapper(), toPersistenceMapper: new OrderToPersistenceMapper(), uow, outboxStore, // ← auto-save enabled }); // Same decorator + publisher pattern const bus = new OutboxEventBusDecorator(rabbitBus, outboxStore); const publisher = new OutboxPublisher(outboxStore, rabbitBus); publisher.start(); ``` ### TypeORM ```typescript theme={null} import { DataSource } from "typeorm"; import { TypeORMUnitOfWork, TypeORMOutboxStore } from "@woltz/rich-domain-typeorm"; import { OutboxEventBusDecorator, OutboxPublisher } from "@woltz/rich-domain-outbox"; const dataSource = new DataSource({ /* ... */ }); const uow = new TypeORMUnitOfWork(dataSource); const outboxStore = new TypeORMOutboxStore(dataSource); // Configure repository with outboxStore in TypeORMRepositoryConfig const repo = new OrderRepository({ typeormRepository: dataSource.getRepository(OrderEntity), toDomainMapper: new OrderToDomainMapper(), toPersistenceMapper: new OrderToPersistenceMapper(), uow, outboxStore, // ← auto-save enabled }); // Same decorator + publisher pattern const bus = new OutboxEventBusDecorator(rabbitBus, outboxStore); const publisher = new OutboxPublisher(outboxStore, rabbitBus); publisher.start(); ``` *** ## How Auto-Save Works When you configure an `outboxStore` on your repository, the `save()` method automatically: 1. **Extracts** uncommitted domain events from the aggregate (using duck-typing — no direct dependency on `BaseAggregate`) 2. **Clears** the events from the aggregate (so `dispatchAll()` won't double-publish) 3. **Saves** the events to the outbox table in the same transaction context ```typescript theme={null} // Inside the repository base class: async save(entity: TDomain): Promise { const events = this.extractEvents(entity); // 1. Extract events await this.toPersistenceMapper.build(entity); // 2. Persist aggregate entity.markAsPersisted(); if (events.length > 0 && this.outboxStore) { await this.outboxStore.save(events); // 3. Save to outbox (same tx) } } ``` This means that even if `dispatchAll()` is never called, your events are safely stored in the outbox. The `OutboxPublisher` will pick them up on the next poll cycle. *** ## API Reference ### OutboxEventBusDecorator Wraps an `IDomainEventBus` to track publish success/failure in the outbox. ```typescript theme={null} class OutboxEventBusDecorator implements IDomainEventBus { constructor( inner: IDomainEventBus, outboxStore: IOutboxStore ); async publish(event: IDomainEvent): Promise; async publishAll(events: IDomainEvent[]): Promise; } ``` | Method | Description | | -------------------- | ---------------------------------------------------------------------------------------------------------------- | | `publish(event)` | Publish immediately. On success → `markPublished(eventId)`. On failure → `markFailed(eventId, error)` + re-throw | | `publishAll(events)` | Same as `publish` but for multiple events. Empty array is a no-op | ### OutboxPublisher Background process that polls the outbox table and publishes pending events. ```typescript theme={null} class OutboxPublisher { constructor( store: IOutboxStore, bus: IDomainEventBus, config?: OutboxPublisherConfig ); start(): void; stop(): Promise; processOnce(): Promise<{ processed: number; failed: number }>; get isRunning(): boolean; } ``` | Method | Description | | --------------- | ---------------------------------------------------------------- | | `start()` | Begin polling loop. Idempotent — safe to call multiple times | | `stop()` | Graceful shutdown. Waits for in-flight batch to complete | | `processOnce()` | Process a single batch (useful for cron jobs or manual triggers) | | `isRunning` | Whether the polling loop is active | ### OutboxPublisherConfig | Option | Default | Description | | ---------------- | --------- | ----------------------------------------------------------- | | `pollIntervalMs` | `5000` | How often to poll the outbox table (milliseconds) | | `batchSize` | `50` | Maximum events to process per poll cycle | | `maxRetries` | `3` | Stop retrying after this many failed attempts | | `logger` | `console` | Logger instance (must have `info`, `warn`, `error` methods) | ### IOutboxStore The contract that all ORM-specific outbox stores implement. ```typescript theme={null} interface IOutboxStore { save(events: IDomainEvent[]): Promise; fetchPending(batchSize?: number): Promise; markPublished(eventId: string): Promise; markFailed(eventId: string, error: string): Promise; } ``` | Method | Description | | ---------------------------- | ---------------------------------------------------------------------------------------------------- | | `save(events)` | Insert events into the outbox table. Uses the transactional client when inside a UoW transaction | | `fetchPending(batchSize?)` | Fetch pending events ordered by `createdAt ASC` | | `markPublished(eventId)` | `UPDATE outbox SET status = 'published' WHERE id = $eventId` — direct PK lookup | | `markFailed(eventId, error)` | `UPDATE outbox SET status = 'failed', retries = retries + 1, lastError = $error WHERE id = $eventId` | ### OutboxEntry Plain immutable class representing an outbox row. ```typescript theme={null} class OutboxEntry { readonly id: string; readonly eventName: string; readonly payload: unknown; readonly occurredOn: Date; readonly status: OutboxStatus; readonly retries: number; readonly lastError: string | null; readonly createdAt: Date; canRetry(maxRetries: number): boolean; get isPublished(): boolean; get isPending(): boolean; get isFailed(): boolean; toJSON(): Record; } ``` ### OutboxStatus ```typescript theme={null} type OutboxStatus = "pending" | "published" | "failed"; ``` *** ## Best Practices ### Polling Interval Tune `pollIntervalMs` to your latency tolerance: | Interval | Use Case | | ------------- | ------------------------------------------------- | | `1000` (1s) | Low-latency requirements, high-throughput systems | | `5000` (5s) | General purpose (default) | | `30000` (30s) | Background processes, analytics events | Polling too frequently adds unnecessary database load. Start with 5 seconds and adjust based on your event volume and latency requirements. ### Batch Size `batchSize` controls how many events are fetched per poll cycle: ```typescript theme={null} // High-throughput system const publisher = new OutboxPublisher(store, bus, { batchSize: 200, pollIntervalMs: 2000, }); // Low-volume system const publisher = new OutboxPublisher(store, bus, { batchSize: 10, pollIntervalMs: 10000, }); ``` ### Graceful Shutdown Always stop the publisher during application shutdown to avoid in-flight message loss: ```typescript theme={null} const publisher = new OutboxPublisher(store, bus); publisher.start(); async function shutdown() { console.log("Shutting down..."); await publisher.stop(); // Waits for current batch to finish process.exit(0); } process.on("SIGTERM", shutdown); process.on("SIGINT", shutdown); ``` ### Monitoring Monitor the outbox table for events stuck in `failed` status: ```sql theme={null} -- Events that have exhausted retries SELECT COUNT(*) FROM outbox WHERE status = 'failed' AND retries >= 3; -- Events stuck in pending (possibly orphaned) SELECT COUNT(*) FROM outbox WHERE status = 'pending' AND createdAt < NOW() - INTERVAL '1 hour'; ``` Set up alerts when these counts exceed a threshold — it means the background publisher isn't keeping up or the broker is down. ### Error Handling The `OutboxEventBusDecorator` re-throws publish errors so your use case code can respond: ```typescript theme={null} try { await order.dispatchAll(bus); } catch (error) { if (error instanceof OutboxPublishError) { // Immediate publish failed, but event is safely in outbox // Respond to the user — the event will be retried return { status: "accepted", retryExpected: true }; } throw error; } ``` *** ## Exports Summary ```typescript theme={null} // Main package: @woltz/rich-domain-outbox export { OutboxEventBusDecorator } from "./outbox-event-bus-decorator"; export { OutboxPublisher } from "./outbox-publisher"; export { OutboxEntry } from "./outbox-entry"; export { OutboxError, OutboxPublishError, OutboxStoreError } from "./outbox-errors"; export { OUTBOX_DDL } from "./outbox-ddl"; export type { OutboxPublisherConfig } from "./outbox-publisher"; // Core types (from @woltz/rich-domain) export type { IOutboxStore, OutboxEntryData, OutboxFetchResult, OutboxStatus }; // ORM adapters export { PrismaOutboxStore, PRISMA_OUTBOX_SCHEMA } from "@woltz/rich-domain-prisma"; export { DrizzleOutboxStore, outboxTable } from "@woltz/rich-domain-drizzle"; export { TypeORMOutboxStore, OutboxEntity } from "@woltz/rich-domain-typeorm"; ``` # Prisma Source: https://woltz.mintlify.app/integrations/prisma Plug-and-play integration between rich-domain and Prisma ORM ## Overview `@woltz/rich-domain-prisma` provides seamless integration between rich-domain and Prisma ORM. It leverages Prisma's nested writes and transaction support to align perfectly with rich-domain's change tracking and Unit of Work patterns. ```bash theme={null} npm install @woltz/rich-domain @woltz/rich-domain-prisma ``` Request-isolated transactions with AsyncLocalStorage PrismaRepository with built-in Criteria support PrismaToPersistence with automatic change detection PrismaBatchExecutor for efficient bulk writes Optional `outboxStore` on the repository for guaranteed event delivery For a complete working example, see the [fastify-with-prisma example](https://github.com/tarcisioandrade/rich-domain/tree/main/examples/backend/fastify-with-prisma) in the repository. ## Quick Start ### 1. Setup ```typescript theme={null} import { PrismaClient } from "@prisma/client"; import { PrismaUnitOfWork } from "@woltz/rich-domain-prisma"; const prisma = new PrismaClient(); const uow = new PrismaUnitOfWork(prisma); ``` ### 2. Create Repository ```typescript theme={null} import { PrismaRepository } from "@woltz/rich-domain-prisma"; class UserRepository extends PrismaRepository { protected readonly model = "user"; protected readonly includes = { posts: true, profile: true }; constructor(prisma: PrismaClient, uow: PrismaUnitOfWork) { super( new UserToPersistenceMapper(prisma, uow), new UserToDomainMapper(), prisma, uow ); } } ``` ### 3. Use It ```typescript theme={null} const userRepository = new UserRepository(prisma, uow); // Create const user = new User({ name: "John", email: "john@example.com", posts: [], }); await userRepository.save(user); // Find with Criteria const criteria = Criteria.create() .where("name", "contains", "John") .orderBy("createdAt", "desc") .paginate(1, 10); const result = await userRepository.find(criteria); // Update with change tracking const found = await userRepository.findById(user.id.value); found.name = "John Updated"; found.posts.push(new Post({ title: "Hello", content: "World" })); await userRepository.save(found); // Only changed data is persisted // Delete await userRepository.delete(found); ``` *** ## PrismaUnitOfWork Manages transactions with per-request isolation using `AsyncLocalStorage`. ```typescript theme={null} import { PrismaUnitOfWork } from "@woltz/rich-domain-prisma"; const uow = new PrismaUnitOfWork(prisma); ``` ### Transaction Execution ```typescript theme={null} // Execute multiple operations atomically await uow.transaction(async () => { await userRepository.save(user); await orderRepository.save(order); await notificationRepository.save(notification); // All or nothing - auto rollback on failure }); ``` ### Request Isolation Each HTTP request gets its own transaction context, preventing cross-request interference: ```typescript theme={null} // Request 1 app.post("/users", async (req, res) => { await uow.transaction(async () => { // This transaction is isolated to Request 1 await userRepository.save(user); }); }); // Request 2 (concurrent) app.get("/users/:id", async (req, res) => { // NOT affected by Request 1's transaction const user = await userRepository.findById(req.params.id); }); ``` ### API Reference | Method | Description | | --------------------- | --------------------------------------- | | `transaction(work)` | Execute work function in a transaction | | `isInTransaction()` | Check if currently in a transaction | | `getCurrentContext()` | Get current transaction context or null | *** ## @Transactional Decorator Decorator that automatically wraps a method in a transaction. ```typescript theme={null} import { Transactional } from "@woltz/rich-domain-prisma"; class CreateUserUseCase { constructor( private readonly userRepository: UserRepository, private readonly uow: PrismaUnitOfWork ) {} @Transactional() async execute(input: CreateUserInput): Promise { // Everything here runs in a transaction automatically const user = new User({ ...input, posts: [] }); await this.userRepository.save(user); return user; } } ``` ### With Explicit UoW Parameter You can pass the UoW instance directly to the decorator instead of relying on constructor injection: ```typescript theme={null} class CreateUserUseCase { constructor(private readonly userRepository: UserRepository) {} @Transactional(myUnitOfWork) async execute(input: CreateUserInput): Promise { // Uses the explicitly provided UoW const user = new User({ ...input, posts: [] }); await this.userRepository.save(user); return user; } } ``` ### UoW Resolution Order The decorator looks for the UoW instance in this order: 1. **Decorator parameter** - `@Transactional(myUow)` 2. **Instance property** - `this.uow` 3. **Private property** - `this._uow` 4. **Any property** - Any property that is a `PrismaUnitOfWork` instance ```typescript theme={null} // Option 1: Via decorator parameter @Transactional(globalUow) async method1() { ... } // Option 2: Via constructor (recommended) class MyService { constructor(private readonly uow: PrismaUnitOfWork) {} @Transactional() async method2() { ... } } // Option 3: Via any instance property class MyService { unitOfWork = new PrismaUnitOfWork(prisma); @Transactional() async method3() { ... } } ``` ### Behavior | Scenario | Behavior | | ---------------------- | ----------------------- | | Direct call | Creates new transaction | | Already in transaction | Reuses existing one | | Error thrown | Automatic rollback | *** ## PrismaRepository Base class for repositories with full Criteria support. ```typescript theme={null} import { PrismaRepository } from "@woltz/rich-domain-prisma"; abstract class PrismaRepository { // Required: Prisma model name protected abstract get model(): string; // Required: Search query generator protected abstract generateSearchQuery(search: string): any[]; // Optional: relations to include protected readonly includes: Record = {}; // Built-in methods async find(criteria: Criteria): Promise>; async findById(id: string): Promise; async findManyByIds(ids: string[]): Promise; async count(criteria?: Criteria): Promise; async exists(id: string): Promise; async save(entity: TDomain): Promise; async delete(entity: TDomain): Promise; async deleteById(id: string): Promise; async transaction(work: () => Promise): Promise; } ``` ### Complete Implementation ```typescript theme={null} import { PrismaRepository, PrismaUnitOfWork, PrismaOutboxStore, } from "@woltz/rich-domain-prisma"; class UserRepository extends PrismaRepository { protected readonly model = "user"; protected readonly includes = { posts: true, profile: true, }; constructor( prisma: PrismaClient, uow: PrismaUnitOfWork, outboxStore?: PrismaOutboxStore ) { super( new UserToPersistenceMapper(prisma, uow), new UserToDomainMapper(), prisma, uow, outboxStore ); } // Required: implement search query generation protected generateSearchQuery(search: string): any[] { return [ { name: { contains: search, mode: "insensitive" } }, { email: { contains: search, mode: "insensitive" } }, ]; } // Custom query methods async findByEmail(email: string): Promise { const data = await this.context.user.findUnique({ where: { email }, include: this.includes, }); return data ? this.toDomainMapper.build(data) : null; } } ``` ### Context Awareness The repository automatically uses the transaction context when available: ```typescript theme={null} class UserRepository extends PrismaRepository { // Use this.context for transaction-aware queries async customQuery(): Promise { const data = await this.context.user.findMany({ where: { status: "active" }, include: this.includes, }); return data.map((d) => this.toDomainMapper.build(d)); } } ``` ### Transactional Outbox Pass an optional **`outboxStore`** as the fifth argument to `PrismaRepository`'s constructor. When set, `save()` automatically persists uncommitted domain events to the outbox table in the **same database transaction** as the aggregate write. | Property | Type | Required | Description | | ------------- | ------------------- | -------- | ---------------------------------------------- | | `outboxStore` | `PrismaOutboxStore` | No | Enables auto-save of domain events on `save()` | Use `PrismaOutboxStore` from `@woltz/rich-domain-prisma`. For the full setup (table migration, event bus decorator, background publisher), see **[Transactional Outbox](/integrations/outbox)**. ```typescript theme={null} import { PrismaOutboxStore } from "@woltz/rich-domain-prisma"; const outboxStore = new PrismaOutboxStore(prisma); class OrderRepository extends PrismaRepository { constructor(prisma: PrismaClient, uow: PrismaUnitOfWork) { super( new OrderToPersistenceMapper(prisma, uow), new OrderToDomainMapper(), prisma, uow, outboxStore // ← events saved atomically with the aggregate ); } } ``` *** ## EntitySchemaRegistry Maps domain entities to database tables, handles field mapping, and configures relationships. See the complete [Schema Registry documentation](/repository/schema-registry) for all features. ### Basic Registration ```typescript theme={null} import { EntitySchemaRegistry } from "@woltz/rich-domain"; const registry = new EntitySchemaRegistry() .register({ entity: "User", table: "user", }) .register({ entity: "Post", table: "post", fields: { content: "main_content", // domain → database mapping }, parentFk: { field: "authorId", parentEntity: "User", }, }); ``` ### Collection Configuration (N:N Relations) For N:N relationships, configure collections with `type: "reference"`: ```typescript theme={null} const registry = new EntitySchemaRegistry() .register({ entity: "User", table: "user", collections: { // 'owned': Children entities that belong exclusively to this aggregate // They are created/deleted with the parent posts: { type: "owned", entity: "Post", }, }, }) .register({ entity: "Post", table: "post", // Parent foreign key for owned entities parentFk: { field: "authorId", parentEntity: "User", }, collections: { // Field name related to the relationship in the domain; // 'posts.tags' <- Domain Relation field name is 'tags' tags: { // 'reference': Independent entities connected via junction table // They exist independently and are only linked/unlinked type: "reference", entity: "Tag", // Optional: set when the Prisma relation field name differs from the domain property name. // e.g. domain property: "tags", Prisma schema field: "post_tags" // relationName: "post_tags", // Junction config required for manually created pivot tables junction: { table: "tagPost", // Pivot table name sourceKey: "postId", // FK to Post targetKey: "tagId", // FK to Tag }, }, }, }); ``` ### Collection Types | Type | Relationship | Batch Behavior | | ----------- | ------------ | --------------------------- | | `owned` | 1:N | `createMany` / `deleteMany` | | `reference` | N:N | `connect` / `disconnect` | The `PrismaBatchExecutor` automatically uses the correct Prisma operations based on collection type: ```typescript theme={null} // For 'owned' collections (1:N): // - Creates use: prisma.post.createMany({ data: [...] }) // - Deletes use: prisma.post.deleteMany({ where: { id: { in: [...] } } }) // For 'reference' collections (N:N): // - Creates use: prisma.post.update({ data: { tags: { connect: [...] } } }) // - Deletes use: prisma.post.update({ data: { tags: { disconnect: [...] } } }) // Note: if the Prisma relation field name differs from the domain property name, // set `relationName` in the collection config so the executor uses the correct field. ``` *** ## PrismaToPersistence Base mapper class with change tracking integration. ```typescript theme={null} import { PrismaToPersistence } from "@woltz/rich-domain-prisma"; import { EntitySchemaRegistry, AggregateChanges } from "@woltz/rich-domain"; abstract class PrismaToPersistence extends Mapper { // Required: registry for field mapping protected abstract readonly registry: EntitySchemaRegistry; // Required: handle entity creation protected abstract onCreate(entity: TDomain): Promise; // Optional: override for custom update logic (default uses PrismaBatchExecutor) protected async onUpdate( changes: AggregateChanges, entity: TDomain ): Promise; // Returns transaction client when inside a transaction, otherwise the prisma client protected get context(): PrismaClient; } ``` ### Complete Example ```typescript theme={null} class UserToPersistenceMapper extends PrismaToPersistence { protected readonly registry = new EntitySchemaRegistry() .register({ entity: "User", table: "user", collections: { posts: { type: "owned" }, tags: { type: "reference", entity: "Tag" }, }, }) .register({ entity: "Post", table: "post", parentFk: { field: "authorId", parentEntity: "User" }, }); protected async onCreate(user: User): Promise { await this.context.user.create({ data: { id: user.id.value, name: user.name, email: user.email, posts: { createMany: { data: user.posts.map((p) => ({ id: p.id.value, title: p.title, content: p.content, authorId: user.id.value, })), }, }, // N:N - connect existing tags tags: { connect: user.tags.map((t) => ({ id: t.id.value })), }, }, }); } // onUpdate uses PrismaBatchExecutor by default — override only if needed } ``` When overriding, use [`changes.without()`](/core/change-tracking#excluding-entities) to persist one entity manually and pass the remaining changes to `super.onUpdate()`. *** ## PrismaBatchExecutor Executes batch operations from `AggregateChanges` with proper ordering and relationship handling. ```typescript theme={null} import { PrismaBatchExecutor } from "@woltz/rich-domain-prisma"; const executor = new PrismaBatchExecutor(context, { registry: schemaRegistry, }); await executor.execute(changes); ``` ### Execution Order The executor respects referential integrity: 1. **Deletes** - Leaf → Root (depth DESC) * `owned`: Uses `deleteMany` * `reference`: Uses `disconnect` 2. **Creates** - Root → Leaf (depth ASC) * `owned`: Uses `createMany` * `reference`: Uses `connect` 3. **Updates** - Any order Batch executors and repositories resolve the correct PK column via `registry.getPrimaryKeyField()` and `registry.buildWhereById()`. Configure `primaryKey` when a table does not use an `id` column (e.g. `factoryProfile.factoryId`). Remember: `primaryKey` names the **database column**; the value always comes from `entity.id`, not from a domain property with the same name. ### Convenience Function ```typescript theme={null} import { executeBatch } from "@woltz/rich-domain-prisma"; // Shorthand for simple cases await executeBatch(context, changes, { registry: schemaRegistry, }); ``` *** ## Complete Example ### Domain Model ```typescript theme={null} import { z } from "zod"; import { Aggregate, Entity, Id, EntityValidation } from "@woltz/rich-domain"; // Post Entity const postSchema = z.object({ id: z.custom((v) => v instanceof Id), title: z.string().min(1), content: z.string(), published: z.boolean().default(false), }); class Post extends Entity> { protected static validation: EntityValidation> = { schema: postSchema, }; get title() { return this.props.title; } get content() { return this.props.content; } get published() { return this.props.published; } publish() { this.props.published = true; } } // Tag Entity (for N:N reference) class Tag extends Entity<{ id: Id; name: string }> { get name() { return this.props.name; } } // User Aggregate const userSchema = z.object({ id: z.custom((v) => v instanceof Id), name: z.string().min(2), email: z.string().email(), posts: z.array(z.custom((v) => v instanceof Post)), tags: z.array(z.custom((v) => v instanceof Tag)), }); class User extends Aggregate> { protected static validation = { schema: userSchema }; get name() { return this.props.name; } set name(value: string) { this.props.name = value; } get email() { return this.props.email; } get posts() { return this.props.posts; } get tags() { return this.props.tags; } addPost(post: Post) { this.props.posts.push(post); } removePost(postId: Id) { const index = this.props.posts.findIndex((p) => p.id.equals(postId)); if (index !== -1) this.props.posts.splice(index, 1); } addTag(tag: Tag) { if (!this.props.tags.some((t) => t.id.equals(tag.id))) { this.props.tags.push(tag); } } removeTag(tagId: Id) { const index = this.props.tags.findIndex((t) => t.id.equals(tagId)); if (index !== -1) this.props.tags.splice(index, 1); } } ``` ### Schema Registry ```typescript theme={null} import { EntitySchemaRegistry } from "@woltz/rich-domain"; const schemaRegistry = new EntitySchemaRegistry() .register({ entity: "User", table: "user", collections: { posts: { type: "owned" }, tags: { type: "reference", entity: "Tag" }, }, }) .register({ entity: "Post", table: "post", parentFk: { field: "authorId", parentEntity: "User" }, }) .register({ entity: "Tag", table: "tag", }); ``` ### Mappers ```typescript theme={null} import { Mapper } from "@woltz/rich-domain"; import { PrismaToPersistence, PrismaBatchExecutor } from "@woltz/rich-domain-prisma"; // Domain Mapper class UserToDomainMapper extends Mapper { build(record: UserRecord): User { return new User({ id: Id.from(record.id), name: record.name, email: record.email, posts: record.posts.map( (p) => new Post({ id: Id.from(p.id), title: p.title, content: p.content, published: p.published, }) ), tags: record.tags.map( (t) => new Tag({ id: Id.from(t.id), name: t.name }) ), }); } } // Persistence Mapper class UserToPersistenceMapper extends PrismaToPersistence { protected readonly registry = schemaRegistry; protected async onCreate(user: User): Promise { await this.context.user.create({ data: { id: user.id.value, name: user.name, email: user.email, posts: { createMany: { data: user.posts.map((p) => ({ id: p.id.value, title: p.title, content: p.content, published: p.published, authorId: user.id.value, })), }, }, tags: { connect: user.tags.map((t) => ({ id: t.id.value })), }, }, }); } // onUpdate uses PrismaBatchExecutor by default — override only if needed } ``` ### Repository ```typescript theme={null} import { PrismaRepository, PrismaUnitOfWork, PrismaOutboxStore, } from "@woltz/rich-domain-prisma"; class UserRepository extends PrismaRepository { protected readonly model = "user"; protected readonly includes = { posts: true, tags: true }; constructor(prisma: PrismaClient, uow: PrismaUnitOfWork) { super( new UserToPersistenceMapper(prisma, uow), new UserToDomainMapper(), prisma, uow ); } protected generateSearchQuery(search: string): any[] { return [ { name: { contains: search, mode: "insensitive" } }, { email: { contains: search, mode: "insensitive" } }, ]; } async findByEmail(email: string): Promise { const data = await this.context.user.findUnique({ where: { email }, include: this.includes, }); return data ? this.toDomainMapper.build(data) : null; } } ``` ### Use Case ```typescript theme={null} import { Transactional } from "@woltz/rich-domain-prisma"; import { EntityNotFoundError } from "@woltz/rich-domain"; class UserService { constructor( private readonly userRepository: UserRepository, private readonly uow: PrismaUnitOfWork ) {} @Transactional() async addTagToUser(userId: string, tag: Tag): Promise { const user = await this.userRepository.findById(userId); if (!user) { throw new EntityNotFoundError("User", userId); } user.addTag(tag); // N:N reference await this.userRepository.save(user); // Uses connect return user; } @Transactional() async removeTagFromUser(userId: string, tagId: string): Promise { const user = await this.userRepository.findById(userId); if (!user) { throw new EntityNotFoundError("User", userId); } user.removeTag(Id.from(tagId)); // N:N reference await this.userRepository.save(user); // Uses disconnect return user; } } ``` *** ## Error Handling The adapter provides specific error types: ```typescript theme={null} import { PrismaRepositoryError, ModelNotFoundError, TableNotFoundError, NoRecordsAffectedError, BatchOperationError, } from "@woltz/rich-domain-prisma"; try { await userRepository.save(user); } catch (error) { if (error instanceof ModelNotFoundError) { // Model not in Prisma schema } else if (error instanceof TableNotFoundError) { // Entity not in registry } else if (error instanceof BatchOperationError) { // Batch operation failed } } ``` *** ## Complete Example See the [fastify-with-prisma example](https://github.com/tarcisioandrade/rich-domain/tree/main/examples/backend/fastify-with-prisma) for a complete working application demonstrating: * User aggregate with Posts (1:N owned) * Post with Tags (N:N reference via junction table) * Case-insensitive search * Transaction management * CRUD operations * Domain events with BullMQ *** ## API Reference ### Exports ```typescript theme={null} // Unit of Work export { PrismaUnitOfWork, Transactional, getCurrentPrismaContext }; export type { PrismaTransactionContext, PrismaClientLike, PrismaTransactionClient }; // Repository export { PrismaRepository }; export type { PrismaRepositoryConfig }; // Outbox export { PrismaOutboxStore, PRISMA_OUTBOX_SCHEMA }; // Mapper export { PrismaToPersistence }; // Batch Executor export { PrismaBatchExecutor, executeBatch }; export type { BatchExecutorConfig }; // Errors export { PrismaRepositoryError, ModelNotFoundError, TableNotFoundError, NoRecordsAffectedError, BatchOperationError, OutboxStoreError, }; ``` ### PrismaRepositoryConfig ```typescript theme={null} interface PrismaRepositoryConfig { prisma: PrismaClientLike; uow: PrismaUnitOfWork; /** Optional — auto-save domain events on `save()`. See [Transactional Outbox](/integrations/outbox). */ outboxStore?: PrismaOutboxStore; } ``` The repository constructor accepts the same options positionally: mappers, `prisma`, `uow`, and optional `outboxStore`. ### PrismaUnitOfWork Methods | Method | Returns | Description | | --------------------- | ---------------------------------- | --------------------------- | | `transaction(work)` | `Promise` | Execute work in transaction | | `isInTransaction()` | `boolean` | Check if in transaction | | `getCurrentContext()` | `PrismaTransactionContext \| null` | Get current context | ### PrismaRepository Methods | Method | Returns | Description | | -------------------- | ----------------------------- | ---------------------- | | `find(criteria)` | `Promise>` | Find with criteria | | `findById(id)` | `Promise` | Find by ID | | `findManyByIds(ids)` | `Promise` | Find multiple by IDs | | `count(criteria?)` | `Promise` | Count entities | | `exists(id)` | `Promise` | Check if exists | | `save(entity)` | `Promise` | Create or update | | `delete(entity)` | `Promise` | Delete entity | | `deleteById(id)` | `Promise` | Delete by ID | | `transaction(work)` | `Promise` | Execute in transaction | # DataKanbanCriteria Source: https://woltz.mintlify.app/integrations/react/data-kanban-criteria A Kanban board component with drag and drop, virtualization, and Criteria integration. Live Demo ## Installation ```bash npm theme={null} npx shadcn add "https://tarcisioandrade.github.io/rich-domain/packages/react-rich-domain/public/r/data-kanban-criteria.json" ``` ```bash pnpm theme={null} pnpm dlx shadcn add "https://tarcisioandrade.github.io/rich-domain/packages/react-rich-domain/public/r/data-kanban-criteria.json" ``` ```bash yarn theme={null} yarn dlx shadcn add "https://tarcisioandrade.github.io/rich-domain/packages/react-rich-domain/public/r/data-kanban-criteria.json" ``` #### Features * Drag and drop between columns with @dnd-kit * Virtualized lists for large datasets with @tanstack/react-virtual * Infinite scroll per column * Horizontal scroll for unlimited columns * Integrated filtering and search * Optimistic updates with automatic rollback on error * Fractional indexing for scalable ordering * Customizable card, column, and empty state rendering * Smart cursor styles (grab/grabbing/pointer) * Prevent drag on specific elements with `data-no-drag` * URL sync for filters and search #### Basic Usage ```tsx theme={null} import { useCriteriaKanban } from "@/hooks/use-criteria-kanban"; import { DataKanbanCriteria, KanbanCard, KanbanCardHeader, KanbanCardTitle, KanbanCardDescription, KanbanCardContent, KanbanCardFooter, KanbanCardBadge, } from "@/components/data-kanban-criteria"; import type { KanbanColumnDefinition } from "@/types/use-criteria-kanban.type"; import type { QueryFilter } from "@/lib/filter-utils"; interface Task { id: string; title: string; description: string; status: "todo" | "doing" | "done"; priority: "low" | "medium" | "high"; order: string; // Fractional index for ordering } // Define columns with criteria filters const columns: KanbanColumnDefinition[] = [ { id: "todo", title: "To Do", criteria: (c) => c.where("status", "equals", "todo"), value: "todo", }, { id: "doing", title: "In Progress", criteria: (c) => c.where("status", "equals", "doing"), value: "doing", }, { id: "done", title: "Done", criteria: (c) => c.where("status", "equals", "done"), value: "done", }, ]; const filterFields: QueryFilter[] = [ { field: "priority", fieldLabel: "Priority", type: "string", multiSelect: true, options: [ { label: "High", value: "high" }, { label: "Medium", value: "medium" }, { label: "Low", value: "low" }, ], }, ]; function TaskKanban() { const kanban = useCriteriaKanban(["tasks", "kanban"], getTasks, { columns, getItemId: (task) => task.id, groupField: "status", filterFields, columnPageSize: 20, syncWithUrl: true, onCardMove: async ({ cardId, toColumn, insertAfterId }) => { // Call your API to move the task await moveTask(cardId, toColumn.id, insertAfterId); }, onMoveError: (error, { fromColumn }) => { toast.error(`Failed to move task. Reverted to ${fromColumn.title}`); }, }); return ( ( {task.title} {task.description} {task.priority} )} estimatedCardHeight={120} /> ); } ``` #### Hook Options ```tsx theme={null} interface UseCriteriaKanbanOptions { // Required columns: KanbanColumnDefinition[]; // Column definitions getItemId: (item: T) => string; // Get unique ID from item groupField: FieldPath; // Field that determines column // Card move handling onCardMove?: (params: CardMoveParams) => Promise; onMoveError?: (error: Error, params: CardMoveParams) => void; // Filter configuration filterFields?: QueryFilter[]; // Search searchDebounceMs?: number; // Default: 300 // Pagination columnPageSize?: number; // Default: 50 // Drag and drop enableDragDrop?: boolean; // Default: true sensors?: SensorDescriptor[]; // Custom dnd-kit sensors // URL sync syncWithUrl?: boolean; } ``` #### Column Definition ```tsx theme={null} interface KanbanColumnDefinition { id: string; // Unique identifier title: string; // Display title criteria: (base: Criteria) => Criteria; // Filter for this column value?: unknown; // Value to set on items when moved here color?: string; // Optional color theme icon?: React.ReactNode; // Optional icon limit?: number; // WIP limit } ``` The `value` property is used when the column `id` differs from the actual value stored in your data. For example, if your column has `id: "in-progress"` but your database stores `status: "DOING"`, set `value: "DOING"`. When a card is moved to this column, the hook will use `value` for optimistic updates. If `value` is omitted, the column `id` is used instead. #### Card Move Parameters When a card is moved, `onCardMove` receives: ```tsx theme={null} interface CardMoveParams { cardId: string; // ID of the moved card item: T; // The complete item data fromColumn: KanbanColumnDefinition; toColumn: KanbanColumnDefinition; toIndex: number; // New position index insertAfterId: string | null; // ID of item above, null if first } ``` The `insertAfterId` pattern is designed for scalability: * Frontend sends only the reference ID (not the order) * Backend queries the real neighbors to calculate the correct order * Works correctly even with filters applied #### Backend Integration Your backend should implement a move endpoint like: ```typescript theme={null} // Backend receives insertAfterId and calculates order async function moveTask( taskId: string, newStatus: string, insertAfterId: string | null ) { let prevOrder: string | null = null; let nextOrder: string | null = null; if (insertAfterId === null) { // Insert at top - find first item's order const firstItem = await db.task.findFirst({ where: { status: newStatus }, orderBy: { order: "asc" }, select: { order: true }, }); nextOrder = firstItem?.order ?? null; } else { // Find the reference task's order const refTask = await db.task.findUnique({ where: { id: insertAfterId }, select: { order: true }, }); prevOrder = refTask!.order; // Find next order after reference const nextItem = await db.task.findFirst({ where: { status: newStatus, order: { gt: prevOrder } }, orderBy: { order: "asc" }, select: { order: true }, }); nextOrder = nextItem?.order ?? null; } // Calculate new order between prev and next const newOrder = generateFractionalIndex(prevOrder, nextOrder); await db.task.update({ where: { id: taskId }, data: { status: newStatus, order: newOrder }, }); } ``` #### Component Props ```tsx theme={null} interface DataKanbanCriteriaProps { kanban: UseCriteriaKanbanReturn; // From useCriteriaKanban renderCard: (item: T, isDragging: boolean, isClickable: boolean) => React.ReactNode; renderColumnHeader?: (column, itemCount) => React.ReactNode; renderColumnFooter?: (column) => React.ReactNode; renderEmptyState?: (column) => React.ReactNode; // Custom empty state onCardClick?: (item: T) => void; // Layout toolbarLayout?: "default" | "compact" | "none"; actionBar?: React.ReactNode; className?: string; columnsClassName?: string; columnClassName?: string; columnsContentScrollClassName?: string; // Virtualization estimatedCardHeight?: number; // Default: 120 showItemCount?: boolean; // Default: true showSkeleton?: boolean; // Default: true } ``` #### Toolbar Layouts ```tsx theme={null} // Default layout - toolbar with filters and search // Compact - smaller toolbar // No toolbar - just the board ``` #### Programmatic Card Move ```tsx theme={null} const { moveCard } = useCriteriaKanban(...); // Move card programmatically await moveCard( cardId, // Card to move fromColumnId, toColumnId, toIndex // Position in target column ); ``` #### Custom Card Components The package includes pre-built card components: ```tsx theme={null} import { KanbanCard, KanbanCardHeader, KanbanCardTitle, KanbanCardDescription, KanbanCardContent, KanbanCardFooter, KanbanCardBadge, } from "@/components/data-kanban-criteria"; {task.title} {task.description} {task.priority} ``` #### Custom Empty State Customize what's shown when a column has no items: ```tsx theme={null} (

No tasks in {column.title}

)} /> ``` If not provided, defaults to a simple "No items" message. #### Preventing Drag on Specific Elements Use the `data-no-drag` attribute on elements that should not trigger dragging: ```tsx theme={null} {task.title} {/* Button won't trigger drag when clicked */} ``` This is useful for interactive elements inside cards like buttons, inputs, or dropdown menus. #### Cursor Styles The `KanbanCard` component automatically handles cursor styles based on state: | State | Cursor | When | | -------------------- | ------ | ----------------------------------- | | `cursor-grab` | ✋ | Card is draggable, no `onCardClick` | | `cursor-pointer` | 👆 | Card has `onCardClick` handler | | `cursor-grabbing` | ✊ | Card is being dragged | | `cursor-not-allowed` | 🚫 | Card is disabled | The `isClickable` parameter in `renderCard` determines if the card should show `cursor-pointer`: ```tsx theme={null} ( {/* ... */} )} onCardClick={(task) => openTaskModal(task)} // When set, isClickable=true /> ``` #### Horizontal Scroll The board automatically supports horizontal scrolling when you have many columns. Each column has a fixed width (320px) and the container scrolls horizontally when columns exceed the viewport. This works out of the box - no configuration needed. #### Full Height Layout To make the kanban fill the viewport height: ```tsx theme={null} ``` # DataTableCriteria Source: https://woltz.mintlify.app/integrations/react/data-table-criteria A complete data table component integrated with React Query and TanStack Table. Live Demo ## Instalation ```bash npm theme={null} npx shadcn add "https://tarcisioandrade.github.io/rich-domain/packages/react-rich-domain/public/r/data-table-criteria.json" ``` ```bash pnpm theme={null} pnpm dlx shadcn add "https://tarcisioandrade.github.io/rich-domain/packages/react-rich-domain/public/r/data-table-criteria.json" ``` ```bash yarn theme={null} yarn dlx shadcn add "https://tarcisioandrade.github.io/rich-domain/packages/react-rich-domain/public/r/data-table-criteria.json" ``` #### Features * Integrated filtering, sorting, and pagination * Search functionality with debouncing * Column visibility toggle * Export to CSV/Excel * Mobile responsive * Customizable action bar #### Basic Usage ```tsx theme={null} import { useCriteriaTable } from "@/hooks/use-criteria-table"; import { DataTableCriteria } from "@/components/data-table-criteria/data-table-criteria"; import type { ColumnDef } from "@tanstack/react-table"; import type { QueryFilter } from "@/lib/filter-utils"; import type { FileFormat } from "./components/data-view-criteria/data-view-toolbar"; interface User { id: string; name: string; email: string; age: number; status: "active" | "inactive"; createdAt: string; } const columns: ColumnDef[] = [ { accessorKey: "name", header: "Name", }, { accessorKey: "email", header: "Email", }, { accessorKey: "age", header: "Age", }, { accessorKey: "status", header: "Status", }, ]; async function getExport(format: FileFormat) { const result = await apiCall(format); return result; } const filterFields: QueryFilter[] = [ { field: "name", fieldLabel: "Name", type: "string" }, { field: "age", fieldLabel: "Age", type: "number" }, { field: "status", fieldLabel: "Status", type: "string", multiSelect: true, options: [ { label: "Active", value: "active" }, { label: "Inactive", value: "inactive" }, ], }, ]; function UserTable() { const { table, criteria } = useCriteriaTable({ filterFields, columns, queryKey: ["users"], queryFn: getUsers, searchOptions: { searchPlaceholder: "Search users...", }, criteriaOptions: { pageSize: 10, syncWithUrl: true, }, }); return ; } ``` # Filter Source: https://woltz.mintlify.app/integrations/react/filter A pre-built, customizable filter UI component that integrates with `useCriteria`. Live Demo ## Instalation ```bash npm theme={null} npx shadcn add "https://tarcisioandrade.github.io/rich-domain/packages/react-rich-domain/public/r/filter.json" ``` ```bash pnpm theme={null} pnpm dlx shadcn add "https://tarcisioandrade.github.io/rich-domain/packages/react-rich-domain/public/r/filter.json" ``` ```bash yarn theme={null} yarn dlx shadcn add "https://tarcisioandrade.github.io/rich-domain/packages/react-rich-domain/public/r/filter.json" ``` ### Basic Usage ```tsx theme={null} import { useCriteria } from "@/hooks/use-criteria"; import { Filter } from "@/components/filter/filter"; import type { QueryFilter } from "@/lib/filter-utils"; interface User { id: string; name: string; email: string; status: "active" | "inactive" | "pending"; age: number; createdAt: Date; } // Define filterable fields const userFields: QueryFilter[] = [ { field: "name", fieldLabel: "Name", type: "string", }, { field: "email", fieldLabel: "Email", type: "string", }, { field: "status", fieldLabel: "Status", type: "string", options: [ { value: "active", label: "Active" }, { value: "inactive", label: "Inactive" }, { value: "pending", label: "Pending" }, ], }, { field: "age", fieldLabel: "Age", type: "number", }, { field: "createdAt", fieldLabel: "Created At", type: "date", }, ]; function UserFilters() { const { filters, addOrReplaceByIndex, removeFilter, clearFilters } = useCriteria(); return ( ); } ``` ### QueryFilter Type ```tsx theme={null} interface QueryFilter { // Field path in the entity (e.g., "name", "profile.bio") field: string; // Display label for the field fieldLabel: string; // Field type determines available operators type: "string" | "number" | "date" | "boolean"; // Optional: mark if field path traverses a collection (1:N or N:N relation) isCollection?: boolean; // Optional: mark if field can be null isNullable?: boolean; // Optional: predefined options for select fields options?: Array<{ value: string; label: string; icon?: React.ReactNode; }>; } ``` ### Field Types and Operators The Filter component automatically shows the appropriate operators based on field type: | Type | Operators | | --------- | -------------------------------------------------------------------------------------------------------------------- | | `string` | equals, notEquals, contains, startsWith, endsWith, in, notIn, isNull, isNotNull | | `number` | equals, notEquals, greaterThan, greaterThanOrEqual, lessThan, lessThanOrEqual, between, in, notIn, isNull, isNotNull | | `date` | equals, notEquals, greaterThan, lessThan, between, isNull, isNotNull | | `boolean` | equals, notEquals, isNull, isNotNull | ### With Options (Select Fields) For fields with predefined values, use the `options` property: ```tsx theme={null} const orderFields: QueryFilter[] = [ { field: "status", fieldLabel: "Order Status", type: "string", options: [ { value: "draft", label: "Draft", icon: }, { value: "confirmed", label: "Confirmed", icon: }, { value: "shipped", label: "Shipped", icon: }, { value: "delivered", label: "Delivered", icon: }, ], }, { field: "priority", fieldLabel: "Priority", type: "string", options: [ { value: "low", label: "Low" }, { value: "medium", label: "Medium" }, { value: "high", label: "High" }, ], }, ]; ``` ### Nested Fields Support for nested entity paths: ```tsx theme={null} const userFields: QueryFilter[] = [ { field: "profile.bio", fieldLabel: "Bio", type: "string", }, { field: "address.city", fieldLabel: "City", type: "string", }, { field: "address.country", fieldLabel: "Country", type: "string", options: [ { value: "US", label: "United States" }, { value: "BR", label: "Brazil" }, { value: "UK", label: "United Kingdom" }, ], }, ]; ``` ### Collection Fields When filtering through a 1:N or N:N relation (e.g., User has many Posts), use `isCollection: true`: ```tsx theme={null} const userFields: QueryFilter[] = [ { field: "name", fieldLabel: "Name", type: "string", }, { // Filtering through a collection requires isCollection: true field: "posts.title", fieldLabel: "Post Title", type: "string", isCollection: true, // ✅ Required for collections }, { field: "posts.status", fieldLabel: "Post Status", type: "string", isCollection: true, options: [ { value: "draft", label: "Draft" }, { value: "published", label: "Published" }, ], }, ]; ``` When `isCollection: true`, the filter will automatically use `{ quantifier: "some" }` to generate the correct Prisma query: ```typescript theme={null} // Without isCollection (incorrect for collections) { posts: { title: { contains: "hello" } } } // ❌ Error // With isCollection: true (correct) { posts: { some: { title: { contains: "hello" } } } } // ✅ Works ``` # Sorting Source: https://woltz.mintlify.app/integrations/react/sorting A pre-built sorting UI component with drag-and-drop support for reordering sort priority. Live Demo ## Instalation ```bash npm theme={null} npx shadcn add "https://tarcisioandrade.github.io/rich-domain/packages/react-rich-domain/public/r/sorting.json" ``` ```bash pnpm theme={null} pnpm dlx shadcn add "https://tarcisioandrade.github.io/rich-domain/packages/react-rich-domain/public/r/sorting.json" ``` ```bash yarn theme={null} yarn dlx shadcn add "https://tarcisioandrade.github.io/rich-domain/packages/react-rich-domain/public/r/sorting.json" ``` ### Basic Usage ```tsx theme={null} import { useCriteria } from "@/hooks/use-criteria"; import { Sorting } from "@/components/sorting"; import type { SortingField } from "@/components/sorting"; interface User { id: string; name: string; email: string; age: number; createdAt: Date; } // Define sortable fields const sortingFields: SortingField[] = [ { field: "name", fieldLabel: "Name" }, { field: "email", fieldLabel: "Email" }, { field: "age", fieldLabel: "Age" }, { field: "createdAt", fieldLabel: "Created At" }, ]; function UserSorting() { const criteria = useCriteria({ initialSort: [{ field: "name", direction: "asc" }], }); return ; } ``` ### SortingField Type ```tsx theme={null} interface SortingField { // Field path in the entity (e.g., "name", "profile.bio") field: string; // Display label for the field fieldLabel: string; } ``` ### Features * **One-click add**: Click a field to add it with default "asc" direction * **Drag-and-drop reordering**: Change sort priority by dragging items * **Toggle direction**: Click the direction button to switch between ascending/descending * **Remove sorts**: Individual remove buttons for each sort * **Clear all**: One button to clear all sorting * **Duplicate prevention**: Automatically prevents duplicate fields ### Drag-and-Drop Sorting The Sorting component uses [@dnd-kit](https://dndkit.com/) for smooth drag-and-drop interactions: ```tsx theme={null} import { Sorting } from "@/components/sorting"; function ProductTable() { const criteria = useCriteria({ initialSort: [ { field: "price", direction: "desc" }, { field: "name", direction: "asc" }, ], }); return (
{/* Your table or list */}
); } ``` ### Multi-level Sorting The order of sorts matters - the first sort is applied first, then the second, and so on: ```tsx theme={null} // Sort by status first, then by name within each status const criteria = useCriteria({ initialSort: [ { field: "status", direction: "asc" }, { field: "name", direction: "asc" }, ], }); // Users will be grouped by status (active, inactive, pending) // Within each status group, they'll be sorted alphabetically by name ``` ### With Data Table Combine Sorting with data tables for a complete solution: ```tsx theme={null} import { useCriteria } from "@/hooks/use-criteria"; import { Sorting } from "@/components/sorting"; import { Filter } from "@/components/filter/filter"; function UserManagement() { const criteria = useCriteria({ pageSize: 10, syncWithUrl: true, }); return (
{/* Toolbar with Filter and Sorting */}
{/* Your table */}
); } ``` *** ## Complete Example A full example combining `useCriteria`, `Filter`, and a data table: ```tsx theme={null} import { useQuery } from "@tanstack/react-query"; import { useCriteria } from "@/hooks/use-criteria"; import { Filter } from "@/components/filter/filter"; import type { QueryFilter } from "@/lib/filter-utils"; import { PaginatedResult } from "@woltz/rich-domain"; interface Product { id: string; name: string; category: string; price: number; stock: number; status: "active" | "draft" | "archived"; createdAt: Date; } const productFields: QueryFilter[] = [ { field: "name", fieldLabel: "Name", type: "string" }, { field: "category", fieldLabel: "Category", type: "string", options: [ { value: "electronics", label: "Electronics" }, { value: "clothing", label: "Clothing" }, { value: "books", label: "Books" }, ], }, { field: "price", fieldLabel: "Price", type: "number" }, { field: "stock", fieldLabel: "Stock", type: "number" }, { field: "status", fieldLabel: "Status", type: "string", options: [ { value: "active", label: "Active" }, { value: "draft", label: "Draft" }, { value: "archived", label: "Archived" }, ], }, { field: "createdAt", fieldLabel: "Created At", type: "date" }, ]; async function fetchProducts(criteria: Criteria) { const params = new URLSearchParams(); // Add filters criteria.getFilters().forEach((f) => { params.set(`${f.field}:${f.operator}`, String(f.value)); }); // Add pagination const pagination = criteria.getPagination(); params.set("page", String(pagination.page)); params.set("limit", String(pagination.limit)); // Add sorting const orders = criteria.getOrders(); if (orders.length > 0) { params.set( "orderBy", orders.map((o) => `${o.field}:${o.direction}`).join(",") ); } const response = await fetch(`/api/products?${params}`); return response.json() as Promise>; } export function ProductList() { const { criteria, filters, sorting, pagination, addOrReplaceByIndex, removeFilter, clearFilters, setSort, setPage, } = useCriteria({ pageSize: 10, syncWithUrl: true, }); const { data, isLoading, error } = useQuery({ queryKey: ["products", criteria.toJSON()], queryFn: () => fetchProducts(criteria), }); if (error) { return
Error loading products
; } return (
{/* Filters */} {/* Table */} {isLoading ? (
Loading...
) : ( {data?.data.map((product) => ( ))}
setSort("name", "asc")}>Name setSort("category", "asc")}>Category setSort("price", "asc")}>Price setSort("stock", "asc")}>Stock setSort("status", "asc")}>Status
{product.name} {product.category} ${product.price} {product.stock} {product.status}
)} {/* Pagination */} {data && (
Showing {pagination.offset + 1} to{" "} {Math.min(pagination.offset + pagination.limit, data.meta.total)} of{" "} {data.meta.total} results
Page {pagination.page} of {data.meta.totalPages}
)}
); } ``` *** ## API Reference ### useCriteria Options | Option | Type | Default | Description | | ---------------- | ---------- | ----------- | -------------------------------- | | `initialPage` | `number` | `1` | Starting page number | | `pageSize` | `number` | `20` | Items per page | | `initialFilters` | `Filter[]` | `[]` | Pre-applied filters | | `initialSort` | `Order[]` | `[]` | Pre-applied sorting | | `initialSearch` | `object` | `undefined` | Pre-applied search | | `onChange` | `function` | `undefined` | Callback when criteria changes | | `persistKey` | `string` | `undefined` | localStorage key for persistence | | `syncWithUrl` | `boolean` | `false` | Sync state with URL params | ### Filter Props | Prop | Type | Required | Description | | --------------------- | --------------- | -------- | ------------------------------ | | `fields` | `QueryFilter[]` | Yes | Available fields for filtering | | `filters` | `Filter[]` | Yes | Current active filters | | `addOrReplaceByIndex` | `function` | Yes | Add or update a filter | | `removeFilter` | `function` | Yes | Remove a filter by index | | `clearFilters` | `function` | Yes | Clear all filters | # Timeline Source: https://woltz.mintlify.app/integrations/react/timeline A timeline component with date-based grouping and infinite scroll support. Live Demo ## Instalation ```bash npm theme={null} npx shadcn add "https://tarcisioandrade.github.io/rich-domain/packages/react-rich-domain/public/r/user-timeline.json" ``` ```bash pnpm theme={null} pnpm dlx shadcn add "https://tarcisioandrade.github.io/rich-domain/packages/react-rich-domain/public/r/user-timeline.json" ``` ```bash yarn theme={null} yarn dlx shadcn add "https://tarcisioandrade.github.io/rich-domain/packages/react-rich-domain/public/r/user-timeline.json" ``` #### Features * Infinite scroll with React Query * Automatic date grouping (hour/day/week/month/year) * Relative time labels ("Today", "Yesterday", etc.) * Integrated search and filtering * Customizable event rendering * Timeline layout options (default or horizontal sidebar) #### Basic Usage ```tsx theme={null} import { useCriteriaTimeline } from "@/hooks/use-criteria-timeline"; import { DataTimelineCriteria } from "@/components/data-timeline-criteria/data-timeline-criteria"; import type { QueryFilter } from "@/lib/filter-utils"; interface Activity { id: string; title: string; description: string; createdAt: string; type: "create" | "update" | "delete"; } const filterFields: QueryFilter[] = [ { field: "type", fieldLabel: "Type", type: "string", options: [ { label: "Created", value: "create" }, { label: "Updated", value: "update" }, { label: "Deleted", value: "delete" }, ], }, ]; function ActivityTimeline() { const timeline = useCriteriaTimeline( ["activities"], getActivities, { dateField: "createdAt", groupBy: "day", filterFields, pageSize: 20, } ); return ( (

{activity.title}

{activity.description}

)} toolbarLayout="default" // or "none" for no toolbar /> ); } ``` #### Timeline Options ```tsx theme={null} interface UseCriteriaTimelineOptions { // Required dateField: FieldPath; // Field to group by // Grouping groupBy?: "hour" | "day" | "week" | "month" | "year"; // Default: "day" sortDirection?: "asc" | "desc"; // Default: "desc" // Filter configuration filterFields?: QueryFilter[]; // Search searchDebounceMs?: number; // Default: 300 // React Query options pageSize?: number; enabled?: boolean; staleTime?: number; gcTime?: number; } ``` #### Toolbar Layouts ```tsx theme={null} // Default layout - toolbar on top // No toolbar - pure timeline ``` # useCriteria Source: https://woltz.mintlify.app/integrations/react/use-criteria React hook to manage criteria API in @woltz/rich-domain ## Instalation ```bash npm theme={null} npx shadcn add "https://tarcisioandrade.github.io/rich-domain/packages/react-rich-domain/public/r/use-criteria.json" ``` ```bash pnpm theme={null} pnpm dlx shadcn add "https://tarcisioandrade.github.io/rich-domain/packages/react-rich-domain/public/r/use-criteria.json" ``` ```bash yarn theme={null} yarn dlx shadcn add "https://tarcisioandrade.github.io/rich-domain/packages/react-rich-domain/public/r/use-criteria.json" ``` ### Basic Usage ```tsx theme={null} import { useCriteria } from "@/hooks/use-criteria"; interface User { id: string; name: string; email: string; status: "active" | "inactive"; age: number; createdAt: Date; } function UserList() { const { criteria, filters, sorting, pagination, search, addFilter, removeFilter, clearFilters, setSort, setPage, setSearch, reset, } = useCriteria({ pageSize: 20, }); // Use criteria.toJSON() to send to your API const fetchUsers = async () => { const response = await fetch( `/api/users?${new URLSearchParams(criteria.toJSON())}` ); return response.json(); }; return
{/* Your UI */}
; } ``` ### Options ```tsx theme={null} interface UseCriteriaOptions { // Pagination initialPage?: number; // Default: 1 pageSize?: number; // Default: 20 // Initial state initialFilters?: Filter, unknown>[]; initialSort?: Order[]; initialSearch?: { fields: FieldPath[]; value: string; }; // Callbacks onChange?: (criteria: Criteria) => void; // Persistence persistKey?: string; // localStorage key syncWithUrl?: boolean; // Sync with URL params (default: false) } ``` ### Return Value ```tsx theme={null} export interface UseCriteriaReturn { criteria: Criteria; filters: Array>; sorting: Order[]; pagination: Pagination; search: Search | null; addFilter: >( field: K, operator: OperatorsForType>, value?: FilterValueFor>, options?: CriteriaOptions ) => void; removeFilter: (index: number) => void; removeFilterByField: (field: FieldPath) => void; clearFilters: () => void; addSort: (field: FieldPath, direction?: OrderDirection) => void; removeSort: (index: number) => void; addOrReplaceByIndex: (props: { field: FieldPath; operator: OperatorsForType>>; value?: FilterValueFor>>; options?: CriteriaOptions; replaceIndex?: number; }) => void; removeSortByField: (field: FieldPath) => void; getFilterByField: >( field: K ) => Filter>> | undefined; getSortByField: >(field: K) => Order | undefined; clearSort: () => void; setPage: (page: number) => void; setPageSize: (size: number) => void; nextPage: () => void; prevPage: () => void; setSearch: (value: string) => void; clearSearch: () => void; reset: () => void; toJSON: () => ReturnType["toJSON"]>; clone: () => Criteria; clearAll: () => void; } ``` ### URL Synchronization Enable `syncWithUrl` to automatically sync criteria state with URL query parameters: ```tsx theme={null} const { criteria, addFilter } = useCriteria({ syncWithUrl: true, }); // Adding a filter updates the URL: addFilter("status", "equals", "active"); // URL: ?status:equals=active&page=1&limit=20 // Users can bookmark and share filtered views! ``` ### localStorage Persistence Use `persistKey` to persist criteria state across page reloads: ```tsx theme={null} const { criteria, filters } = useCriteria({ persistKey: "user-list-filters", }); // Filters are automatically saved to localStorage // and restored on component mount ``` *** # useCriteriaInfiniteQuery Source: https://woltz.mintlify.app/integrations/react/use-criteria-infinite-query A specialized hook for infinite scroll patterns using React Query's `useInfiniteQuery`. ## Instalation ```bash npm theme={null} npx shadcn add "https://tarcisioandrade.github.io/rich-domain/packages/react-rich-domain/public/r/use-criteria-infinite-query.json" ``` ```bash pnpm theme={null} pnpm dlx shadcn add "https://tarcisioandrade.github.io/rich-domain/packages/react-rich-domain/public/r/use-criteria-infinite-query.json" ``` ```bash yarn theme={null} yarn dlx shadcn add "https://tarcisioandrade.github.io/rich-domain/packages/react-rich-domain/public/r/use-criteria-infinite-query.json" ``` #### Usage ```tsx theme={null} import { useCriteriaInfiniteQuery } from "@/hooks/use-criteria-infinite-query"; function InfiniteList() { const { data, // All accumulated data pages, // Array of page results meta, // Last page metadata fetchNextPage, hasNextPage, isFetchingNextPage, // ... all useCriteria methods } = useCriteriaInfiniteQuery( ["users"], getUsers, { pageSize: 20, enabled: true, } ); return (
{data.map((user) => ( ))} {hasNextPage && ( )}
); } ``` #### Features * Automatic page accumulation * Built-in loading states * Proper TypeScript types * All `useCriteria` methods available * Seamless React Query integration # useCriteriaQuery Source: https://woltz.mintlify.app/integrations/react/use-criteria-query A powerful hook that combines Criteria for filter/sort/pagination management with React Query for data fetching, caching, and state management. ## Instalation ```bash npm theme={null} npx shadcn add "https://tarcisioandrade.github.io/rich-domain/packages/react-rich-domain/public/r/use-criteria-query.json" ``` ```bash pnpm theme={null} pnpm dlx shadcn add "https://tarcisioandrade.github.io/rich-domain/packages/react-rich-domain/public/r/use-criteria-query.json" ``` ```bash yarn theme={null} yarn dlx shadcn add "https://tarcisioandrade.github.io/rich-domain/packages/react-rich-domain/public/r/use-criteria-query.json" ``` ### Basic Usage ```tsx theme={null} import { useCriteriaQuery } from "@/hooks/use-criteria-query"; import type { PaginatedJsonResult } from "@woltz/rich-domain"; interface User { id: string; name: string; email: string; status: "active" | "inactive"; age: number; createdAt: Date; } function UserList() { const { data, isLoading, isError, error, meta, addFilter, setPage, setSearch, clearFilters, } = useCriteriaQuery( "users", async (criteria) => { const params = new URLSearchParams( Object.entries(criteria.toJSON()).map(([key, value]) => [ key, JSON.stringify(value), ]) ); const response = await fetch(`/api/users?${params}`); return response.json(); }, { pageSize: 20, staleTime: 5 * 60 * 1000, // 5 minutes } ); if (isLoading) return
Loading...
; if (isError) return
Error: {error?.message}
; return (
setSearch(e.target.value)} />
    {data.map((user) => (
  • {user.name} - {user.email}
  • ))}
{meta && (
Page {meta.page} of {meta.totalPages}
)}
); } ``` ### Options ```tsx theme={null} interface UseCriteriaQueryOptions extends UseCriteriaOptions { // React Query options enabled?: boolean; // Enable/disable query staleTime?: number; // Time before data is considered stale gcTime?: number; // Garbage collection time refetchInterval?: number | false; // Auto-refetch interval refetchOnMount?: boolean | "always"; // Refetch on component mount refetchOnWindowFocus?: boolean | "always"; // Refetch on window focus refetchOnReconnect?: boolean | "always"; // Refetch on reconnect retry?: boolean | number; // Retry failed requests retryDelay?: number | ((attempt: number) => number); // Delay between retries // Callbacks onSuccess?: (data: PaginatedJsonResult) => void; onError?: (error: TError) => void; onSettled?: ( data: PaginatedJsonResult | undefined, error: TError | null ) => void; // Data transformation select?: (data: PaginatedJsonResult) => unknown; // UseCriteria options (inherited) initialPage?: number; // Default: 1 pageSize?: number; // Default: 20 initialFilters?: Filter, unknown>[]; initialSort?: Order[]; initialSearch?: { fields: FieldPath[]; value: string; }; onChange?: (criteria: Criteria) => void; persistKey?: string; // localStorage key syncWithUrl?: boolean; // Sync with URL params } ``` ### Return Value ```tsx theme={null} interface UseCriteriaQueryReturn { // Data data: TData[]; meta: PaginationMeta | undefined; // Loading states isLoading: boolean; isFetching: boolean; isRefetching: boolean; isError: boolean; isSuccess: boolean; // Error error: TError | null; // Actions refetch: () => Promise; // Criteria state criteria: Criteria; filters: Filter[]; sorting: Order[]; pagination: Pagination; // Filter actions addFilter: >( field: K, operator: OperatorsForType>, value?: FilterValueFor> ) => void; removeFilter: (index: number) => void; removeFilterByField: (field: FieldPath) => void; clearFilters: () => void; // Sort actions addSort: (field: FieldPath, direction: OrderDirection) => void; removeSort: (index: number) => void; removeSortByField: (field: FieldPath) => void; clearSort: () => void; // Pagination actions setPage: (page: number) => void; setPageSize: (size: number) => void; nextPage: () => void; prevPage: () => void; // Search actions setSearch: (value: string) => void; clearSearch: () => void; // Reset reset: () => void; } ``` ### Advanced Examples #### With URL Synchronization Keep your filters in sync with the URL so users can bookmark and share filtered views: ```tsx theme={null} const { data, addFilter, setPage } = useCriteriaQuery( "users", fetchUsers, { syncWithUrl: true, pageSize: 20, } ); // Changing filters updates the URL addFilter("status", "equals", "active"); // URL: ?status:equals=active&page=1&limit=20 ``` #### With Persistence Save filter state to localStorage: ```tsx theme={null} const { data, filters } = useCriteriaQuery("users", fetchUsers, { persistKey: "user-filters", pageSize: 20, }); // Filters are automatically saved and restored ``` ### Building Query Strings The hook expects you to build the query string for your API. Here's a helper function: ```tsx theme={null} function buildUrlWithCriteria( baseUrl: string, criteria: Criteria ): string { const json = criteria.toJSON(); const params = new URLSearchParams(); // Add filters json.filters.forEach((filter) => { const key = `${filter.field}:${filter.operator}`; const value = Array.isArray(filter.value) ? filter.value.join(",") : String(filter.value); params.set(key, value); }); // Add ordering if (json.orders.length > 0) { params.set( "orderBy", json.orders.map((o) => `${o.field}:${o.direction}`).join(",") ); } // Add pagination if (json.pagination) { params.set("page", String(json.pagination.page)); params.set("limit", String(json.pagination.limit)); } // Add search if (json.search) { params.set("search", json.search.value); } return `${baseUrl}?${params}`; } // Usage const { data } = useCriteriaQuery("users", async (criteria) => { const url = buildUrlWithCriteria("/api/users", criteria); const response = await fetch(url); return response.json(); }); ``` ### Type Safety The hook provides full type safety for your filters and sorting: ```tsx theme={null} interface User { id: string; name: string; age: number; email: string; status: "active" | "inactive"; } const { addFilter, addSort } = useCriteriaQuery("users", fetchUsers); // Type-safe field names addFilter("name", "contains", "john"); // ✓ Valid addFilter("invalidField", "equals", "value"); // ✗ TypeScript error // Type-safe operators for each field type addFilter("age", "greaterThan", 18); // ✓ Valid (number operator) addFilter("age", "contains", "18"); // ✗ TypeScript error (contains is for strings) // Type-safe values addFilter("status", "equals", "active"); // ✓ Valid addFilter("status", "equals", "invalid"); // ✗ TypeScript error // Type-safe sorting addSort("createdAt", "desc"); // ✓ Valid addSort("invalidField", "asc"); // ✗ TypeScript error ``` # TypeORM Source: https://woltz.mintlify.app/integrations/typeorm TypeORM adapter with change tracking, batch operations, and transaction management ## Overview `@woltz/rich-domain-typeorm` provides full integration between rich-domain and TypeORM, bringing Domain-Driven Design patterns with automatic change tracking and batch operations. ```bash theme={null} npm install @woltz/rich-domain-typeorm @woltz/rich-domain typeorm ``` Automatic detection and persistence of aggregate changes Optimized bulk inserts, updates, and deletes Smart handling of owned (1:N) and reference (N:N) collections Full ACID compliance with @Transactional decorator Optional `outboxStore` in repository config for guaranteed event delivery For a complete working example, see the [fastify-with-typeorm example](https://github.com/tarcisioandrade/rich-domain/tree/main/examples/backend/fastify-with-typeorm) in the repository. *** ## Quick Start ### 1. Setup DataSource and UnitOfWork ```typescript theme={null} import { DataSource } from "typeorm"; import { TypeORMUnitOfWork } from "@woltz/rich-domain-typeorm"; const dataSource = new DataSource({ type: "postgres", host: "localhost", port: 5432, username: "user", password: "password", database: "mydb", entities: [UserEntity, PostEntity, TagEntity], synchronize: true, }); await dataSource.initialize(); const uow = new TypeORMUnitOfWork(dataSource); ``` ### 2. Define Domain Entity ```typescript theme={null} import { Aggregate, Id, EntityValidation } from "@woltz/rich-domain"; import { z } from "zod"; const userSchema = z.object({ id: z.instanceof(Id), email: z.string().email(), name: z.string(), posts: z.array(z.instanceof(Post)), createdAt: z.date(), updatedAt: z.date(), }); type UserProps = z.infer; export class User extends Aggregate { protected static validation: EntityValidation = { schema: userSchema, }; get email() { return this.props.email; } get name() { return this.props.name; } get posts() { return this.props.posts; } addPost(post: Post): void { this.props.posts.push(post); } removePost(postId: Id): void { const index = this.props.posts.findIndex(p => p.id.equals(postId)); if (index !== -1) { this.props.posts.splice(index, 1); } } } ``` ### 3. Create TypeORM Entity ```typescript theme={null} import { Entity, PrimaryColumn, Column, OneToMany } from "typeorm"; @Entity("users") export class UserEntity { @PrimaryColumn("uuid") id!: string; @Column() email!: string; @Column() name!: string; @OneToMany(() => PostEntity, post => post.author) posts!: PostEntity[]; @Column() createdAt!: Date; @Column() updatedAt!: Date; } ``` ### 4. Create Mappers ```typescript theme={null} import { Mapper, Id } from "@woltz/rich-domain"; import { TypeORMToPersistence, TypeORMToDomain, } from "@woltz/rich-domain-typeorm"; import { EntitySchemaRegistry } from "@woltz/rich-domain"; // Domain Mapper export class UserToDomainMapper extends Mapper { build(entity: UserEntity): User { return User.reconstitute({ id: Id.from(entity.id), email: entity.email, name: entity.name, posts: entity.posts?.map(p => this.mapPost(p)) ?? [], createdAt: entity.createdAt, updatedAt: entity.updatedAt, }); } private mapPost(entity: PostEntity): Post { return Post.reconstitute({ id: Id.from(entity.id), title: entity.title, content: entity.mainContent, published: entity.published, createdAt: entity.createdAt, updatedAt: entity.updatedAt, }); } } // Persistence Mapper export class UserToPersistenceMapper extends TypeORMToPersistence { protected readonly registry = new EntitySchemaRegistry() .register({ entity: "User", table: "users", collections: { posts: { type: "owned", entity: "Post" }, }, }) .register({ entity: "Post", table: "posts", fields: { content: "main_content" }, parentFk: { field: "authorId", parentEntity: "User" }, }); protected readonly entityClasses = new Map any>([ ["User", UserEntity], ["Post", PostEntity], ]); protected async onCreate(aggregate: User, em: EntityManager): Promise { const entity = new UserEntity(); entity.id = aggregate.id.value; entity.email = aggregate.email; entity.name = aggregate.name; entity.createdAt = aggregate.createdAt; entity.updatedAt = aggregate.updatedAt; await em.save(entity); for (const post of aggregate.posts) { const postEntity = new PostEntity(); postEntity.id = post.id.value; postEntity.title = post.title; postEntity.mainContent = post.content; postEntity.authorId = aggregate.id.value; await em.save(postEntity); } } } ``` ### 5. Create Repository ```typescript theme={null} import { TypeORMRepository, SearchableField, } from "@woltz/rich-domain-typeorm"; export class UserRepository extends TypeORMRepository { constructor( typeormRepo: Repository, toDomainMapper: UserToDomainMapper, toPersistenceMapper: UserToPersistenceMapper, uow: TypeORMUnitOfWork ) { super({ typeormRepository: typeormRepo, toDomainMapper, toPersistenceMapper, uow, alias: "user", }); } protected getDefaultRelations(): string[] { return ["posts"]; } protected getSearchableFields(): SearchableField[] { return ["name", "email", "posts.title"]; } } ``` ### 6. Use It ```typescript theme={null} const userRepo = new UserRepository( dataSource.getRepository(UserEntity), new UserToDomainMapper(), new UserToPersistenceMapper(uow), uow ); // Create const user = new User({ id: Id.create(), email: "john@example.com", name: "John", posts: [], createdAt: new Date(), updatedAt: new Date(), }); await userRepo.save(user); // Find with Criteria const criteria = Criteria.create() .whereEquals("name", "John") .orderByDesc("createdAt") .paginate(1, 10); const result = await userRepo.find(criteria); // Update with change tracking user.addPost(new Post({ ... })); await userRepo.save(user); // Only persists the new post ``` *** ## TypeORMUnitOfWork Manages transactions with per-request isolation using AsyncLocalStorage. ### Setup ```typescript theme={null} import { TypeORMUnitOfWork } from "@woltz/rich-domain-typeorm"; const uow = new TypeORMUnitOfWork(dataSource); ``` The DataSource must be initialized before creating the UnitOfWork. ### Transaction Execution ```typescript theme={null} await uow.transaction(async () => { await userRepository.save(user); await orderRepository.save(order); // All or nothing - auto rollback on failure }); ``` ### Request Isolation Each HTTP request gets its own transaction context: ```typescript theme={null} // Request 1 app.post("/users", async (req, res) => { await uow.transaction(async () => { // Isolated to Request 1 await userRepository.save(user); }); }); // Request 2 (concurrent) app.get("/users/:id", async (req, res) => { // NOT affected by Request 1's transaction const user = await userRepository.findById(req.params.id); }); ``` ### API Reference | Method | Returns | Description | | --------------------------- | ----------------------------------- | ------------------------------- | | `transaction(fn)` | `Promise` | Execute function in transaction | | `isInTransaction()` | `boolean` | Check if in active transaction | | `getCurrentContext()` | `TypeORMTransactionContext \| null` | Get current context | | `getCurrentEntityManager()` | `EntityManager` | Get current or default manager | | `getDataSource()` | `DataSource` | Get underlying DataSource | *** ## @Transactional Decorator Automatically wraps methods in transactions. ```typescript theme={null} import { Transactional } from "@woltz/rich-domain-typeorm"; class UserService { constructor( private readonly userRepo: UserRepository, private readonly uow: TypeORMUnitOfWork ) {} @Transactional() async createUserWithPosts(data: CreateUserData): Promise { const user = new User({ ...data, posts: [] }); await this.userRepo.save(user); for (const postData of data.posts) { user.addPost(new Post(postData)); } await this.userRepo.save(user); return user; // Commits on success, rolls back on error } } ``` ### Nested Transactions The decorator is idempotent - if already in a transaction, it reuses it: ```typescript theme={null} @Transactional() async outer() { await this.methodA(); // Uses same transaction await this.methodB(); // Uses same transaction } @Transactional() async methodA() { // Detects existing transaction and reuses it } ``` ### With Explicit UoW ```typescript theme={null} @Transactional(myUnitOfWork) async execute() { // Uses the explicitly provided UoW } ``` *** ## TypeORMRepository Base class for repositories with full Criteria support. ### Configuration ```typescript theme={null} export class UserRepository extends TypeORMRepository { constructor(config: TypeORMRepositoryConfig) { super(config); } // Override for default relations (eager loading) protected getDefaultRelations(): string[] { return ["posts", "posts.tags"]; } // Override for searchable fields protected getSearchableFields(): SearchableField[] { return [ "name", // Case-insensitive (default) "email", { field: "code", caseSensitive: true }, // Case-sensitive "posts.title", // Nested relation ]; } } ``` ### Transactional Outbox Pass an optional **`outboxStore`** in `TypeORMRepositoryConfig`. When set, `save()` automatically persists uncommitted domain events to the outbox table in the **same database transaction** as the aggregate write. | Property | Type | Required | Description | | ------------- | -------------------- | -------- | ---------------------------------------------- | | `outboxStore` | `TypeORMOutboxStore` | No | Enables auto-save of domain events on `save()` | Use `TypeORMOutboxStore` from `@woltz/rich-domain-typeorm`. For the full setup (entity registration, event bus decorator, background publisher), see **[Transactional Outbox](/integrations/outbox)**. ```typescript theme={null} import { TypeORMOutboxStore } from "@woltz/rich-domain-typeorm"; const outboxStore = new TypeORMOutboxStore(dataSource); const repo = new OrderRepository({ typeormRepository: dataSource.getRepository(OrderEntity), toDomainMapper: new OrderToDomainMapper(), toPersistenceMapper: new OrderToPersistenceMapper(), uow, outboxStore, // ← events saved atomically with the aggregate }); ``` ### Methods | Method | Returns | Description | | ------------------- | ----------------------------- | ----------------------- | | `find(criteria?)` | `Promise>` | Find with criteria | | `findById(id)` | `Promise` | Find by ID | | `findOne(criteria)` | `Promise` | Find first match | | `count(criteria?)` | `Promise` | Count matching entities | | `exists(id)` | `Promise` | Check if exists | | `save(entity)` | `Promise` | Save (create or update) | | `delete(entity)` | `Promise` | Delete entity | ### Criteria Queries ```typescript theme={null} const criteria = Criteria.create() .whereEquals("status", "active") .where("age", "greaterThan", 18) .whereContains("email", "@company.com") .search("john") .orderByDesc("createdAt") .paginate(1, 20); const result = await userRepo.find(criteria); // result.data - User[] // result.meta - { page, limit, total, totalPages } ``` ### Case-Insensitive Search ```typescript theme={null} protected getSearchableFields(): SearchableField[] { return [ "title", // Case-insensitive { field: "code", caseSensitive: true }, // Case-sensitive "author.name", // Nested relation ]; } // Usage const criteria = Criteria.create() .search("hello"); // Generates: // WHERE (LOWER(post.title) LIKE LOWER('%hello%') // OR LOWER(author.name) LIKE LOWER('%hello%')) ``` *** ## TypeORMToPersistence Base class for mapping domain aggregates to persistence. ### Registry Configuration ```typescript theme={null} export class UserToPersistenceMapper extends TypeORMToPersistence { protected readonly registry = new EntitySchemaRegistry() .register({ entity: "User", table: "users", collections: { posts: { type: "owned", entity: "Post" }, }, }) .register({ entity: "Post", table: "posts", fields: { content: "main_content", // Domain field → DB column }, parentFk: { field: "authorId", parentEntity: "User", }, collections: { tags: { type: "reference", // N:N entity: "Tag", junction: { table: "_PostToTag", sourceKey: "A", targetKey: "B", }, }, }, }); protected readonly entityClasses = new Map([ ["User", UserEntity], ["Post", PostEntity], ["Tag", TagEntity], ]); protected async onCreate(aggregate: User, em: EntityManager): Promise { // Create root entity const entity = new UserEntity(); entity.id = aggregate.id.value; entity.email = aggregate.email; entity.name = aggregate.name; await em.save(entity); // Create owned entities for (const post of aggregate.posts) { const postEntity = new PostEntity(); postEntity.id = post.id.value; postEntity.title = post.title; postEntity.authorId = aggregate.id.value; await em.save(postEntity); } } } ``` ### Collection Types | Type | Behavior | Use Case | | ----------- | --------------------------------- | -------------------------------- | | `owned` | Creates/deletes child entities | 1:N relationships (User → Posts) | | `reference` | Connects/disconnects via junction | N:N relationships (Post ↔ Tags) | *** ## TypeORMBatchExecutor Executes batch operations from AggregateChanges. ### Execution Order 1. **Deletes** (leaf → root by depth DESC) * Owned: Delete entities * Reference: Unlink from junction table 2. **Creates** (root → leaf by depth ASC) * Owned: Create entities * Reference: Insert into junction table 3. **Updates** (any order) ### Direct Usage ```typescript theme={null} import { TypeORMBatchExecutor } from "@woltz/rich-domain-typeorm"; const executor = new TypeORMBatchExecutor({ registry: schemaRegistry, entityManager: uow.getCurrentEntityManager(), entityClasses: new Map([ ["Post", PostEntity], ["Comment", CommentEntity], ["Tag", TagEntity], ]), }); await executor.execute(aggregateChanges); ``` ### Convenience Function ```typescript theme={null} import { executeBatch } from "@woltz/rich-domain-typeorm"; await executeBatch(entityManager, changes, { registry: schemaRegistry, entityClasses: entityClassMap, }); ``` *** ## N:N Relations ### Configuration ```typescript theme={null} protected readonly registry = new EntitySchemaRegistry() .register({ entity: "Post", table: "posts", collections: { tags: { type: "reference", entity: "Tag", junction: { table: "_PostToTag", // Junction table name sourceKey: "A", // Column for Post ID targetKey: "B", // Column for Tag ID }, }, }, }); ``` ### Usage ```typescript theme={null} const post = await postRepo.findById(postId); // Add tag (inserts into junction table) post.addTag(new Tag({ id: Id.from("promo"), name: "Promo" })); await postRepo.save(post); // → INSERT INTO "_PostToTag" ("A", "B") VALUES (postId, 'promo') // Remove tag (deletes from junction table) post.removeTag(Id.from("promo")); await postRepo.save(post); // → DELETE FROM "_PostToTag" WHERE "A" = postId AND "B" = 'promo' ``` *** ## Error Handling The adapter provides specific error types: ```typescript theme={null} import { TypeORMAdapterError, EntityClassNotFoundError, TableNotFoundError, BatchOperationError, NoRecordsAffectedError, TypeORMRepositoryError, } from "@woltz/rich-domain-typeorm"; try { await userRepo.save(user); } catch (error) { if (error instanceof EntityClassNotFoundError) { // Entity class not registered in entityClasses map } else if (error instanceof TableNotFoundError) { // Entity not in registry } else if (error instanceof BatchOperationError) { // Batch operation failed } else if (error instanceof TypeORMRepositoryError) { // General repository error } } ``` *** ## Complete Example See the [fastify-with-typeorm example](https://github.com/tarcisioandrade/rich-domain/tree/main/examples/backend/fastify-with-typeorm) for a complete working application demonstrating: * User aggregate with Posts (1:N owned) * Post with Tags (N:N reference via junction table) * Case-insensitive search * Transaction management * CRUD operations * Domain events with BullMQ *** ## API Reference ### Exports ```typescript theme={null} // Unit of Work export { TypeORMUnitOfWork, UOWStorage, getCurrentTypeORMContext }; export type { TypeORMTransactionContext }; // Repository export { TypeORMRepository }; export type { TypeORMRepositoryConfig }; // Outbox export { TypeORMOutboxStore, OutboxEntity }; // Mappers export { TypeORMToPersistence }; export { TypeORMToDomain }; // Batch Executor export { TypeORMBatchExecutor, executeBatch }; export type { TypeORMBatchExecutorConfig }; // Query Builder export { TypeORMQueryBuilder }; export type { SearchableField, SearchableFieldConfig }; // Decorator export { Transactional }; // Errors export { TypeORMAdapterError, EntityClassNotFoundError, TableNotFoundError, BatchOperationError, NoRecordsAffectedError, TypeORMRepositoryError, OutboxStoreError, }; ``` ### TypeORMRepositoryConfig ```typescript theme={null} interface TypeORMRepositoryConfig { typeormRepository: Repository; toDomainMapper: Mapper; toPersistenceMapper: TypeORMToPersistence; uow: TypeORMUnitOfWork; alias?: string; // Default: "entity" /** Optional — auto-save domain events on `save()`. See [Transactional Outbox](/integrations/outbox). */ outboxStore?: TypeORMOutboxStore; } ``` ### SearchableField ```typescript theme={null} type SearchableField = | keyof T // Simple field | `${string}.${string}` // Nested field | { field: string; caseSensitive?: boolean; // Default: false }; ``` ### EntitySchemaRegistry Configuration ```typescript theme={null} interface SchemaConfig { entity: string; table: string; fields?: Record; collections?: Record; parentFk?: { field: string; parentEntity: string; }; } ``` # Zod Criteria Source: https://woltz.mintlify.app/integrations/zod Type-safe Zod schemas for Criteria query parameters validation ## Overview `@woltz/rich-domain-criteria-zod` provides Zod schema builders for validating Criteria query parameters. It's framework-agnostic and works with any HTTP framework that supports Zod validation (Fastify, Express, Hono, tRPC, etc.). ```bash theme={null} npm install @woltz/rich-domain-criteria-zod ``` ### Peer Dependencies ```bash theme={null} npm install @woltz/rich-domain zod ``` Define filterable fields with their types and operators Control which fields can be used for ordering Configure default and max pagination values Works with Fastify, Express, Hono, tRPC, and more ## Quick Start ```typescript theme={null} import { defineFilters, CriteriaQuerySchema, PaginatedResponseSchema, } from "@woltz/rich-domain-criteria-zod"; import { z } from "zod"; // 1. Define filterable fields const filters = defineFilters((f) => ({ name: f.string(), email: f.string(), age: f.number(), isActive: f.boolean(), createdAt: f.date(), tags: f.array.string(), })); // 2. Create query schema with orderable fields whitelist const querySchema = CriteriaQuerySchema(filters, { orderBy: ["name", "createdAt", "age"] as const, pagination: { defaultLimit: 20, maxLimit: 100, }, }); // 3. Define response schema const UserDto = z.object({ id: z.string(), name: z.string(), email: z.string(), }); const responseSchema = PaginatedResponseSchema(UserDto); ``` *** ## defineFilters Define which fields can be filtered and their types. ```typescript theme={null} import { defineFilters } from "@woltz/rich-domain-criteria-zod"; const filters = defineFilters((f) => ({ // Basic types name: f.string(), email: f.string(), age: f.number(), isActive: f.boolean(), createdAt: f.date(), // Arrays tags: f.array.string(), scores: f.array.number(), roles: f.array.enum(["admin", "user", "guest"]), // Custom operators (restrict available operators) status: f.string({ operators: ["equals", "in"] }), // Nested paths (for relations) ["author.name"]: f.string(), ["profile.bio"]: f.string(), })); ``` ### Field Types and Operators | Method | Default Operators | | ------------- | -------------------------------------------------------------------------------------------------------------------- | | `f.string()` | equals, notEquals, contains, startsWith, endsWith, in, notIn, isNull, isNotNull | | `f.number()` | equals, notEquals, greaterThan, greaterThanOrEqual, lessThan, lessThanOrEqual, in, notIn, between, isNull, isNotNull | | `f.date()` | Same as number | | `f.boolean()` | equals, notEquals, isNull, isNotNull | | `f.array.*` | in, notIn, isNull, isNotNull | | `f.enum()` | Set yours custom string value | ### Restricting Operators You can limit which operators are available for a field: ```typescript theme={null} const filters = defineFilters((f) => ({ role: f.enum(["ADMIN", "COMMON"]), // Only allow exact match and list status: f.string({ operators: ["equals", "in"] }), // Only allow comparison operators price: f.number({ operators: ["greaterThan", "lessThan", "between"] }), })); ``` *** ## CriteriaQuerySchema Creates a complete query schema with filters, ordering, pagination, and search. ```typescript theme={null} import { CriteriaQuerySchema } from "@woltz/rich-domain-criteria-zod"; const querySchema = CriteriaQuerySchema(filters, { // Whitelist of orderable fields (required for type safety) orderBy: ["name", "createdAt", "email"] as const, // Pagination defaults pagination: { defaultPage: 1, defaultLimit: 20, maxLimit: 100, }, }); ``` ### Why Whitelist for orderBy? Not all filterable fields should be orderable: * **Array fields** can't be ordered * **Nested relations** may not support ordering in your ORM * **Non-indexed fields** could cause performance issues ```typescript theme={null} const filters = defineFilters((f) => ({ name: f.string(), // ✅ Can order tags: f.array.string(), // ❌ Can't order arrays ["author.name"]: f.string() // ⚠️ May not support ordering })); const querySchema = CriteriaQuerySchema(filters, { orderBy: ["name"] as const, // Only include orderable fields }); ``` ### Query Format The schema accepts query parameters in this format: ``` GET /users?filters[name:contains]=John&orderBy=createdAt:desc&pagination[page]=1&pagination[limit]=10&search=test ``` Or as JSON (for POST requests): ```json theme={null} { "filters": { "name:contains": "John", "age:greaterThan": 18 }, "orderBy": ["createdAt:desc", "name:asc"], "pagination": { "page": 1, "limit": 10 }, "search": "test" } ``` *** ## PaginatedResponseSchema Creates a response schema that matches `PaginatedResult.toJSON()` output. ```typescript theme={null} import { PaginatedResponseSchema } from "@woltz/rich-domain-criteria-zod"; const UserDto = z.object({ id: z.string(), name: z.string(), email: z.string(), createdAt: z.string().datetime(), }); const responseSchema = PaginatedResponseSchema(UserDto); // Resulting type: // { // data: UserDto[], // meta: { // page: number, // limit: number, // total: number, // totalPages: number // } // } ``` *** ## Framework Integration ### Fastify ```typescript theme={null} import Fastify from "fastify"; import { ZodTypeProvider } from "fastify-type-provider-zod"; import { Criteria } from "@woltz/rich-domain"; const app = Fastify().withTypeProvider(); app.route({ method: "GET", url: "/users", schema: { querystring: querySchema, response: { 200: responseSchema }, }, handler: async (request) => { // request.query is fully typed! const criteria = Criteria.fromQueryParams(request.query); return userService.list(criteria); }, }); ``` ### Express ```typescript theme={null} import express from "express"; import { Criteria } from "@woltz/rich-domain"; const app = express(); app.get("/users", (req, res) => { const result = querySchema.safeParse(req.query); if (!result.success) { return res.status(400).json({ errors: result.error.flatten() }); } const criteria = Criteria.fromQueryParams(result.data); const users = await userService.list(criteria); res.json(users); }); ``` ### Hono ```typescript theme={null} import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { Criteria } from "@woltz/rich-domain"; const app = new Hono(); app.get( "/users", zValidator("query", querySchema), async (c) => { const query = c.req.valid("query"); const criteria = Criteria.fromQueryParams(query); const users = await userService.list(criteria); return c.json(users); } ); ``` ### tRPC ```typescript theme={null} import { router, publicProcedure } from "./trpc"; import { Criteria } from "@woltz/rich-domain"; export const userRouter = router({ list: publicProcedure .input(querySchema) .output(responseSchema) .query(async ({ input }) => { const criteria = Criteria.fromQueryParams(input); return userService.list(criteria); }), }); ``` *** ## Complete Example ```typescript theme={null} import { defineFilters, CriteriaQuerySchema, PaginatedResponseSchema, } from "@woltz/rich-domain-criteria-zod"; import { z } from "zod"; import { Criteria } from "@woltz/rich-domain"; // ===== Schema Definitions ===== const userFilters = defineFilters((f) => ({ // Basic fields name: f.string(), email: f.string(), // Numeric age: f.number(), balance: f.number(), // Dates createdAt: f.date(), lastLoginAt: f.date(), // Boolean isVerified: f.boolean(), // Arrays roles: f.array.enum(["admin", "user", "moderator"]), tags: f.array.string(), // Nested ["profile.country"]: f.string(), ["company.name"]: f.string(), // Enum status: f.enum(["active", "inactive", "pending"],{ operators: ["equals", "in"] }), })); const userQuerySchema = CriteriaQuerySchema(userFilters, { orderBy: ["name", "email", "createdAt", "lastLoginAt", "balance"] as const, pagination: { defaultPage: 1, defaultLimit: 20, maxLimit: 100, }, }); const UserDto = z.object({ id: z.string().uuid(), name: z.string(), email: z.string().email(), status: z.enum(["active", "inactive", "pending"]), age: z.number().nullable(), isVerified: z.boolean(), roles: z.array(z.string()), createdAt: z.string().datetime(), }); const userResponseSchema = PaginatedResponseSchema(UserDto); // ===== Route Handler ===== // Fastify example app.route({ method: "GET", url: "/api/users", schema: { querystring: userQuerySchema, response: { 200: userResponseSchema }, }, handler: async (request, reply) => { const criteria = Criteria.fromQueryParams(request.query); const result = await userRepository.find(criteria); return result.toJSON(); }, }); // ===== Usage Examples ===== // GET /api/users?filters[name:contains]=john&filters[status:in]=active,pending&orderBy=createdAt:desc&pagination[limit]=10 // GET /api/users?filters[age:between]=18,65&filters[isVerified:equals]=true // GET /api/users?filters[roles:in]=admin,moderator&filters[profile.country:equals]=US ``` *** ## Type Utilities ### InferCriteriaQuery Extract TypeScript type from a query schema: ```typescript theme={null} import { InferCriteriaQuery } from "@woltz/rich-domain-criteria-zod"; type UserQuery = InferCriteriaQuery; // { // filters?: { ... }, // orderBy?: "name:asc" | "name:desc" | "createdAt:asc" | ..., // pagination?: { page: number, limit: number }, // search?: string // } ``` ### OrderEnum Create order enum type from field names: ```typescript theme={null} import { OrderEnum } from "@woltz/rich-domain-criteria-zod"; type UserOrderEnum = OrderEnum<["name", "createdAt", "email"]>; // "name:asc" | "name:desc" | "createdAt:asc" | "createdAt:desc" | "email:asc" | "email:desc" ``` *** ## API Reference ### defineFilters ```typescript theme={null} function defineFilters( shape: (queryBuilder: QueryBuilder) => Record ): ZodObject<...> ``` ### CriteriaQuerySchema ```typescript theme={null} function CriteriaQuerySchema( filterSchema: F, options?: { orderBy?: O; pagination?: { defaultPage?: number; defaultLimit?: number; maxLimit?: number; }; } ): ZodType> ``` ### PaginatedResponseSchema ```typescript theme={null} function PaginatedResponseSchema>( itemSchema: T ): ZodObject<{ data: ZodArray; meta: ZodObject<{ page: ZodNumber; limit: ZodNumber; total: ZodNumber; totalPages: ZodNumber; }>; }> ``` ### QueryBuilder Methods | Method | Description | Operators | | ---------------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------- | | `f.string()` | String field | equals, notEquals, contains, startsWith, endsWith, in, notIn, isNull, isNotNull | | `f.number()` | Number field | equals, notEquals, greaterThan, greaterThanOrEqual, lessThan, lessThanOrEqual, in, notIn, between, isNull, isNotNull | | `f.date()` | Date field | Same as number | | `f.boolean()` | Boolean field | equals, notEquals, isNull, isNotNull | | `f.enum()` | Enum values | Array with string values | | `f.array.string()` | String array | in, notIn, isNull, isNotNull | | `f.array.number()` | Number array | in, notIn, isNull, isNotNull | | `f.array.boolean()` | Boolean array | in, notIn, isNull, isNotNull | | `f.array.date()` | Date array | in, notIn, isNull, isNotNull | | `f.array.enum(values)` | Enum array | in, notIn, isNull, isNotNull | | `f.array.of(schema)` | Custom schema array | in, notIn, isNull, isNotNull | # Introduction Source: https://woltz.mintlify.app/introduction A TypeScript library for Domain-Driven Design with Standard Schema validation, automatic change tracking, and enterprise-ready repositories ## What is rich-domain? **@woltz/rich-domain** is a TypeScript library that provides building blocks for implementing Domain-Driven Design (DDD) patterns with minimal boilerplate and maximum type safety. Full TypeScript support with inference for field paths, filters, and operations Works with Zod, Valibot, ArkType, or any Standard Schema compatible library Track changes across nested entities and collections without boilerplate Use with Prisma, Drizzle, TypeORM, or any persistence layer ## Skills Woltz/rich-domain provides skills that can be added to your AI coding assistant to help it understand rich-domain best practices and implementation patterns. ```shellscript theme={null} npx skills add tarcisioandrade/rich-domain ``` Build your first aggregate from scratch without relying on an ORM. Build using we Prisma Adapter. Build using we TypeORM Adapter. Build using we Drizzle Adapter. # Without ORM Source: https://woltz.mintlify.app/quickstart/default Use Rich Domain with raw SQL or any database driver ## Installation Only the core library is needed: ```bash npm theme={null} npm install @woltz/rich-domain zod ``` ```bash pnpm theme={null} pnpm add @woltz/rich-domain zod ``` ```bash yarn theme={null} yarn add @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. ```typescript theme={null} 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((val) => val instanceof Id), productId: z.string().min(1), quantity: z.number().int().positive(), unitPrice: z.number().positive(), }); type OrderItemProps = z.infer; class OrderItem extends Entity { protected static validation: EntityValidation = { 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((val) => val instanceof Id), customerId: z.string().min(1), status: z.enum(["draft", "confirmed", "shipped"]), items: z.array(z.custom((val) => val instanceof OrderItem)), createdAt: z.date(), }); type OrderProps = z.infer; class Order extends Aggregate { protected static validation: EntityValidation = { 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(); } 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` ```typescript theme={null} 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> = { Order: { customerId: "customer_id", status: "status" }, OrderItem: { productId: "product_id", quantity: "quantity", unitPrice: "unit_price", }, }; class PostgresOrderRepository { async save(order: Order): Promise { 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 { 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): Promise> { 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 { 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: ```sql theme={null} 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 ```typescript theme={null} 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() .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: ```typescript theme={null} class PostgresOrderRepository { async withTransaction(fn: (client: PoolClient) => Promise): Promise { 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: ```typescript theme={null} // 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 Learn advanced repository patterns Understand how changes are tracked Type-safe query building Core DDD concepts # With Drizzle Source: https://woltz.mintlify.app/quickstart/q-drizzle Complete guide to using Rich Domain with Drizzle ORM For a complete working example, see the [fastify-with-drizzle example](https://github.com/tarcisioandrade/rich-domain/tree/main/examples/backend/fastify-with-drizzle) in the repository. ## Installation ```bash npm theme={null} npm install @woltz/rich-domain @woltz/rich-domain-drizzle drizzle-orm pg zod npm install -D drizzle-kit @types/pg ``` ```bash pnpm theme={null} pnpm add @woltz/rich-domain @woltz/rich-domain-drizzle drizzle-orm pg zod pnpm add -D drizzle-kit @types/pg ``` ```bash yarn theme={null} yarn add @woltz/rich-domain @woltz/rich-domain-drizzle drizzle-orm pg zod yarn add -D drizzle-kit @types/pg ``` ## Define the Database Schema Create `src/infrastructure/database/schema.ts`: ```typescript theme={null} import { pgTable, uuid, text, boolean, timestamp, primaryKey, } from "drizzle-orm/pg-core"; import { relations } from "drizzle-orm"; export const users = pgTable("users", { id: uuid("id").primaryKey(), email: text("email").notNull().unique(), name: text("name").notNull(), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }); export const posts = pgTable("posts", { id: uuid("id").primaryKey(), title: text("title").notNull(), content: text("content").notNull(), published: boolean("published").notNull().default(false), authorId: uuid("author_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }); export const tags = pgTable("tags", { id: uuid("id").primaryKey(), }); // Junction table for N:N (Post ↔ Tag) export const postsToTags = pgTable( "posts_to_tags", { postId: uuid("post_id") .notNull() .references(() => posts.id, { onDelete: "cascade" }), tagId: uuid("tag_id") .notNull() .references(() => tags.id, { onDelete: "cascade" }), }, (t) => [primaryKey({ columns: [t.postId, t.tagId] })] ); // Relations for Drizzle relational query API export const usersRelations = relations(users, ({ many }) => ({ posts: many(posts), })); export const postsRelations = relations(posts, ({ one, many }) => ({ author: one(users, { fields: [posts.authorId], references: [users.id] }), tags: many(postsToTags), })); export const postsToTagsRelations = relations(postsToTags, ({ one }) => ({ post: one(posts, { fields: [postsToTags.postId], references: [posts.id] }), tag: one(tags, { fields: [postsToTags.tagId], references: [tags.id] }), })); // Inferred types for mappers export type UserRecord = typeof users.$inferSelect; export type PostRecord = typeof posts.$inferSelect & { tags?: Array<{ tag: typeof tags.$inferSelect }>; }; export type UserWithPosts = UserRecord & { posts: PostRecord[] }; ``` ## Initialize the Database Create `src/infrastructure/database/db.ts`: ```typescript theme={null} import { drizzle } from "drizzle-orm/node-postgres"; import { Pool } from "pg"; import * as schema from "./schema"; let pool: Pool | null = null; let db: ReturnType> | null = null; export async function initializeDatabase() { pool = new Pool({ connectionString: process.env.DATABASE_URL }); await pool.query("SELECT 1"); // verify connection db = drizzle(pool, { schema }); } export function getDb() { if (!db) throw new Error("Database not initialized."); return db; } export async function closeDatabase() { await pool?.end(); db = null; pool = null; } ``` ## Define Domain Models ```typescript theme={null} import { z } from "zod"; import { Aggregate, Entity, EntityValidation, Id } from "@woltz/rich-domain"; // Tag — independent reference entity export class Tag extends Entity<{ id: Id }> {} // Post — owned entity (belongs to User) const postSchema = z.object({ id: z.custom(), title: z.string().min(1), content: z.string().min(1), published: z.boolean(), authorId: z.string(), tags: z.array(z.instanceof(Tag)), createdAt: z.date(), updatedAt: z.date(), }); export type PostProps = z.infer; export class Post extends Aggregate { protected static validation: EntityValidation = { schema: postSchema, }; static restore(props: PostProps): Post { return new Post(props); } addTag(tag: Tag) { this.props.tags.push(tag); } removeTag(tag: Tag) { this.props.tags = this.props.tags.filter((t) => !t.id.equals(tag.id)); } publish() { this.props.published = true; } get title() { return this.props.title; } get content() { return this.props.content; } get published() { return this.props.published; } get authorId() { return this.props.authorId; } get tags() { return this.props.tags; } get createdAt() { return this.props.createdAt; } get updatedAt() { return this.props.updatedAt; } } // User — aggregate root const userSchema = z.object({ id: z.custom(), email: z.string().email(), name: z.string().min(1), posts: z.array(z.instanceof(Post)), createdAt: z.date(), updatedAt: z.date(), }); export type UserProps = z.infer; export class User extends Aggregate { protected static validation: EntityValidation = { schema: userSchema, }; static create(props: Omit): User { return new User({ ...props, createdAt: new Date(), updatedAt: new Date() }); } static restore(props: UserProps): User { return new User(props); } addPost(post: Post) { this.props.posts.push(post); } updateName(name: string) { this.props.name = name; this.props.updatedAt = new Date(); } get email() { return this.props.email; } get name() { return this.props.name; } get posts() { return this.props.posts; } get createdAt() { return this.props.createdAt; } get updatedAt() { return this.props.updatedAt; } } ``` ## Create Mappers ### ToDomain Mapper ```typescript theme={null} import { Mapper, Id } from "@woltz/rich-domain"; import { UserWithPosts } from "../schema"; export class UserToDomainMapper extends Mapper { build(record: UserWithPosts): User { return User.restore({ id: new Id(record.id), email: record.email, name: record.name, createdAt: record.createdAt, updatedAt: record.updatedAt, posts: (record.posts ?? []).map((p) => Post.restore({ id: new Id(p.id), title: p.title, content: p.content, published: p.published, authorId: p.authorId, createdAt: p.createdAt, updatedAt: p.updatedAt, tags: (p.tags ?? []).map(({ tag }) => new Tag({ id: new Id(tag.id) })), }) ), }); } } ``` ### ToPersistence Mapper ```typescript theme={null} import { EntitySchemaRegistry } from "@woltz/rich-domain"; import { DrizzleToPersistence, DrizzleUnitOfWork, Transactional } from "@woltz/rich-domain-drizzle"; import { users, posts, tags, postsToTags } from "../schema"; export const userRegistry = new EntitySchemaRegistry() .register({ entity: "User", table: "users", collections: { posts: { type: "owned", entity: "Post" }, }, }) .register({ entity: "Post", table: "posts", parentFk: { field: "authorId", parentEntity: "User" }, collections: { tags: { type: "reference", entity: "Tag", junction: { table: "posts_to_tags", // must match tableMap key sourceKey: "postId", targetKey: "tagId", }, }, }, }) .register({ entity: "Tag", table: "tags" }); type DB = ReturnType; export class UserToPersistenceMapper extends DrizzleToPersistence { protected readonly registry = userRegistry; protected readonly tableMap = new Map([ ["User", users], ["Post", posts], ["Tag", tags], ["posts_to_tags", postsToTags], // junction table ]); constructor(db: DB, uow: DrizzleUnitOfWork) { super(db, uow); } @Transactional() protected async onCreate(user: User): Promise { // Insert aggregate root await this.context.insert(users).values({ id: user.id.value, email: user.email, name: user.name, createdAt: user.createdAt, updatedAt: user.updatedAt, }); // Insert owned children (Posts) if (user.posts.length > 0) { await this.context.insert(posts).values( user.posts.map((p) => ({ id: p.id.value, title: p.title, content: p.content, published: p.published, authorId: user.id.value, createdAt: p.createdAt, updatedAt: p.updatedAt, })) ); } } // onUpdate is handled automatically by DrizzleBatchExecutor } ``` ## Create Repository ```typescript theme={null} import { DrizzleRepository, DrizzleUnitOfWork, SearchableField } from "@woltz/rich-domain-drizzle"; import { eq } from "drizzle-orm"; import { users, UserWithPosts } from "../schema"; type DB = ReturnType; export class UserRepository extends DrizzleRepository { constructor(db: DB, uow: DrizzleUnitOfWork) { super({ db, table: users, toDomainMapper: new UserToDomainMapper(), toPersistenceMapper: new UserToPersistenceMapper(db, uow), uow, }); } protected get model() { return "users"; // key in db.query — matches Drizzle schema export name } protected getSearchableFields(): SearchableField[] { return ["name", "email"]; } protected getDefaultRelations() { return { posts: { with: { tags: { with: { tag: true } } } }, }; } async findByEmail(email: string): Promise { const record = await this.context.query.users.findFirst({ where: eq(users.email, email), with: this.getDefaultRelations(), }); if (!record) return null; const user = this.toDomainMapper.build(record as any); user.markAsClean(); return user; } } ``` ## Usage ### Setup UnitOfWork and Repository ```typescript theme={null} import { DrizzleUnitOfWork } from "@woltz/rich-domain-drizzle"; await initializeDatabase(); const db = getDb(); const uow = new DrizzleUnitOfWork(db); const userRepo = new UserRepository(db, uow); ``` ### Create ```typescript theme={null} import { Criteria, Id } from "@woltz/rich-domain"; // Create user (posts: [] on creation) const user = User.create({ name: "John Doe", email: "john@example.com", posts: [] }); await userRepo.save(user); // ✅ User inserted into DB ``` ### Create with Nested Entities ```typescript theme={null} // User.restore() pre-embeds posts — onCreate handles both in one transaction const userId = new Id(); const post = Post.restore({ id: new Id(), title: "Hello World", content: "First post", published: false, authorId: userId.value, tags: [], createdAt: new Date(), updatedAt: new Date(), }); const user = User.restore({ id: userId, name: "John", email: "john@example.com", posts: [post], createdAt: new Date(), updatedAt: new Date(), }); await userRepo.save(user); // ✅ User and Post inserted atomically ``` ### Find with Criteria ```typescript theme={null} // Filter + order + paginate (top-level columns only) const criteria = Criteria.create() .whereEquals("name", "John") .orderByAsc("createdAt") .paginate(1, 10); const result = await userRepo.find(criteria); // result.data → User[] // result.toJSON().meta.total → total count // result.toJSON().meta.hasNext → boolean ``` ### Update ```typescript theme={null} const user = await userRepo.findById(userId); user.updateName("Jane Doe"); // tracked by ChangeTracker await userRepo.save(user); // ✅ Only changed fields are updated in DB ``` ### Connect / Disconnect (N:N Reference) ```typescript theme={null} // Connect — adds row to posts_to_tags const post = await postRepo.findById(postId); post.addTag(new Tag({ id: new Id(tagId) })); await postRepo.save(post); // Disconnect — removes row from posts_to_tags post.removeTag(new Tag({ id: new Id(tagId) })); await postRepo.save(post); ``` ### Remove Owned Entity from Collection ```typescript theme={null} const user = await userRepo.findById(userId); const index = user.posts.findIndex((p) => p.id.value === postIdToRemove); if (index !== -1) user.props.posts.splice(index, 1); await userRepo.save(user); // ✅ Post row is deleted from DB ``` ### Delete ```typescript theme={null} await userRepo.delete(user); await userRepo.deleteById(userId); ``` ### Transactions ```typescript theme={null} await uow.transaction(async () => { const user = User.create({ name: "Alice", email: "alice@example.com", posts: [] }); await userRepo.save(user); const post = new Post({ ... }); await postRepo.save(post); // Both committed atomically, or both rolled back on error }); ``` *** ## Key Differences from Prisma | Topic | Prisma | Drizzle | | ------------------- | ----------------------------------------- | ---------------------------------------- | | N:N junction | Optional (Prisma manages implicit tables) | **Always required** | | `onCreate` | Nested writes via Prisma API | Manual `INSERT` with Drizzle SQL | | Criteria dot paths | Supported via `include` | **Not supported** — use custom methods | | Relational queries | `include: { posts: true }` | `with: { posts: true }` (relational API) | | `contains` operator | Case-insensitive (all DBs) | Uses `ILIKE` (PostgreSQL only) | *** ## Limitations **Criteria dot-field paths are not supported.** Filters, ordering, and search fields must reference top-level columns of the primary table. Using `"profile.name"`, `"posts.title"`, or any dotted path throws a `DrizzleAdapterError`. Add custom repository methods with explicit JOINs for cross-table queries. *** ## Transactional Outbox (optional) If your aggregates emit domain events, pass an optional **`outboxStore`** in `DrizzleRepositoryConfig`. When set, `save()` persists uncommitted events to the outbox table in the **same transaction** as the aggregate — so events are not lost if the process crashes before `dispatchAll()`. See **[Transactional Outbox](/integrations/outbox)** for the full setup (`outboxTable`, event bus decorator, background publisher). Repository wiring is documented under [Drizzle Integration → Transactional Outbox](/integrations/drizzle#transactional-outbox). ## Next Steps Deep dive into all Drizzle adapter features Guaranteed domain event delivery with the outbox pattern Learn advanced repository patterns Understand how changes are tracked Full Schema Registry reference # With Prisma Source: https://woltz.mintlify.app/quickstart/q-prisma Complete guide to using Rich Domain with Prisma ORM For a complete working example, see the [fastify-with-prisma example](https://github.com/tarcisioandrade/rich-domain/tree/main/examples/backend/fastify-with-prisma) in the repository. ## Installation ```bash npm theme={null} npm install @woltz/rich-domain @woltz/rich-domain-prisma @prisma/client zod npm install -D prisma ``` ```bash pnpm theme={null} pnpm add @woltz/rich-domain @woltz/rich-domain-prisma @prisma/client zod pnpm add -D prisma ``` ```bash yarn theme={null} yarn add @woltz/rich-domain @woltz/rich-domain-prisma @prisma/client zod yarn add -D prisma ``` ## Setup Prisma Initialize and configure Prisma: ```bash theme={null} npx prisma init ``` Define your schema in `prisma/schema.prisma`: ```typescript theme={null} generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } // Aggregate Root model User { id String @id @default(uuid()) email String @unique name String posts Post[] // 1:N owned collection createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } // Owned Entity (belongs to User) model Post { id String @id @default(uuid()) title String content String published Boolean @default(false) authorId String author User @relation(fields: [authorId], references: [id], onDelete: Cascade) tagPosts TagPost[] // N:N reference collection createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } // Reference Entity (independent, shared across Posts) model Tag { id String @id @default(uuid()) name String @unique tagPosts TagPost[] } // Junction table for N:N (manually created) model TagPost { postId String tagId String post Post @relation(fields: [postId], references: [id], onDelete: Cascade) tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) @@id([postId, tagId]) } ``` Generate Prisma Client: ```bash theme={null} npx prisma generate npx prisma db push ``` ## Initialize Prisma Client ```typescript theme={null} import { PrismaClient } from "@prisma/client"; import { PrismaUnitOfWork } from "@woltz/rich-domain-prisma"; export const prisma = new PrismaClient(); export const uow = new PrismaUnitOfWork(prisma); ``` ## Define Domain Models ### Domain Entities ```typescript theme={null} import { z } from "zod"; import { Aggregate, Entity, EntityValidation, Id } from "@woltz/rich-domain"; // Tag (Entity - independent reference, tracked by identity in N:N relations) export class Tag extends Entity<{ id: Id }> {} // Post (Entity - owned by User) const postSchema = z.object({ id: z.custom((val) => val instanceof Id), title: z.string().min(3), content: z.string(), published: z.boolean(), authorId: z.string(), tags: z.array(z.custom()), createdAt: z.date(), updatedAt: z.date(), }); type PostProps = z.infer; export class Post extends Entity { protected static validation: EntityValidation = { schema: postSchema }; get title() { return this.props.title; } get content() { return this.props.content; } get published() { return this.props.published; } get authorId() { return this.props.authorId; } get tags() { return this.props.tags; } addTag(tag: Tag) { if (!this.props.tags.some(t => t.id.equals(tag.id))) { this.props.tags.push(tag); } } removeTag(tagId: Id) { const index = this.props.tags.findIndex(t => t.id.equals(tagId)); if (index !== -1) { this.props.tags.splice(index, 1); } } publish() { this.props.published = true; } } // User (Aggregate Root) const userSchema = z.object({ id: z.custom((val) => val instanceof Id), email: z.string().email(), name: z.string().min(2), posts: z.array(z.custom()), createdAt: z.date(), updatedAt: z.date(), }); type UserProps = z.infer; export class User extends Aggregate { protected static validation: EntityValidation = { schema: userSchema }; get email() { return this.props.email; } get name() { return this.props.name; } get posts() { return this.props.posts; } addPost(post: Post) { this.props.posts.push(post); } removePost(postId: Id) { const index = this.props.posts.findIndex(p => p.id.equals(postId)); if (index !== -1) { this.props.posts.splice(index, 1); } } updateName(newName: string) { this.props.name = newName; } } ``` ## Create Schema Types ```typescript theme={null} import { Prisma } from "@prisma/client"; export type UserSchema = Prisma.UserGetPayload<{ include: { posts: { include: { tagPosts: { include: { tag: true; }; }; }; }; }; }>; ``` ## Create Mappers ### ToDomain Mapper ```typescript theme={null} import { Mapper, Id } from "@woltz/rich-domain"; import { UserSchema } from "./schemas/user.schema"; export class UserToDomainMapper extends Mapper { build(raw: UserSchema): User { return new User({ id: Id.from(raw.id), email: raw.email, name: raw.name, posts: raw.posts.map(post => new Post({ id: Id.from(post.id), title: post.title, content: post.content, published: post.published, authorId: post.authorId, tags: post.tagPosts.map(tp => new Tag({ id: Id.from(tp.tag.id) })), createdAt: post.createdAt, updatedAt: post.updatedAt, }) ), createdAt: raw.createdAt, updatedAt: raw.updatedAt, }); } } ``` ### ToPersistence Mapper ```typescript theme={null} import { PrismaClient } from "@prisma/client"; import { PrismaToPersistence, PrismaBatchExecutor } from "@woltz/rich-domain-prisma"; import { AggregateChanges, EntitySchemaRegistry } from "@woltz/rich-domain"; export class UserToPersistenceMapper extends PrismaToPersistence { protected readonly registry = new EntitySchemaRegistry() .register({ entity: "User", table: "user", collections: { // 'owned': Children entities that belong exclusively to this aggregate // They are created/deleted with the parent posts: { type: "owned", entity: "Post", }, }, }) .register({ entity: "Post", table: "post", // Parent foreign key for owned entities parentFk: { field: "authorId", parentEntity: "User", }, collections: { // Field name related to the relationship in the domain; // 'posts.tags' <- Domain Relation field name is 'tags' tags: { // 'reference': Independent entities connected via junction table // They exist independently and are only linked/unlinked type: "reference", entity: "Tag", // Junction config required for manually created pivot tables junction: { table: "tagPost", // Pivot table name sourceKey: "postId", // FK to Post targetKey: "tagId", // FK to Tag }, }, }, }); protected async onCreate(user: User): Promise { // Create aggregate root await this.context.user.create({ data: { id: user.id.value, email: user.email, name: user.name, createdAt: new Date(), updatedAt: new Date(), }, }); // Create owned entities (Posts) for (const post of user.posts) { await this.context.post.create({ data: { id: post.id.value, title: post.title, content: post.content, published: post.published, authorId: user.id.value, createdAt: new Date(), updatedAt: new Date(), }, }); // Create junction records for referenced entities (Tags) if (post.tags.length > 0) { await this.context.tagPost.createMany({ data: post.tags.map(tag => ({ postId: post.id.value, tagId: tag.id.value, })), skipDuplicates: true, }); } } } // onUpdate uses PrismaBatchExecutor by default — override only if needed } ``` **Default `onUpdate` behavior** (via `PrismaBatchExecutor`): * Root updates (User fields) * Owned entity changes (Post creates/updates/deletes) * Reference changes (Tag link/unlink in junction table) Override `onUpdate` only when you need custom persistence logic. Use [`changes.without()`](/core/change-tracking#excluding-entities) to handle one entity manually and delegate the rest to `super.onUpdate()`. **Collection Types:** * **`owned`**: Entity belongs to and is lifecycle-managed by the aggregate (e.g., User → Posts). Created and deleted with the parent. * **`reference`**: Independent entity that exists separately (e.g., Post ↔ Tags). Only the relationship is managed via junction table. **Junction Table:** * Only needed when you manually create the pivot table (like `TagPost`) * If using Prisma's implicit many-to-many (`@relation`), no junction config needed ## Create Repository ```typescript theme={null} import { PrismaRepository } from "@woltz/rich-domain-prisma"; import { WriteAndRead } from "@woltz/rich-domain"; import { Prisma } from "@prisma/client"; interface IUserRepository extends WriteAndRead { findByEmail(email: string): Promise; } export class UserRepository extends PrismaRepository implements IUserRepository { constructor() { super( new UserToPersistenceMapper(prisma, uow), new UserToDomainMapper(), prisma, uow ); } protected get model() { return "user"; } protected generateSearchQuery(search: string) { return [ { name: { contains: search, mode: "insensitive" } }, { email: { contains: search, mode: "insensitive" } }, ] satisfies Prisma.UserWhereInput[]; } protected readonly includes = { posts: { include: { tagPosts: { include: { tag: true, }, }, }, }, } satisfies Prisma.UserInclude; async findByEmail(email: string): Promise { const data = await this.context.user.findUnique({ where: { email }, include: this.includes, }); return data ? this.toDomainMapper.build(data) : null; } } ``` ## Usage ```typescript theme={null} const userRepo = new UserRepository(); // Create user with posts and tags const user = new User({ email: "john@example.com", name: "John Doe", posts: [], createdAt: new Date(), updatedAt: new Date(), }); // Add post with tags const post = Post.restore({ title: "My First Post", content: "Hello World", published: false, authorId: user.id.value, tags: [ new Tag({ id: Id.from("tag-1") }), new Tag({ id: Id.from("tag-2") }), ], createdAt: new Date(), updatedAt: new Date(), }); user.addPost(post); // Save everything in one transaction await userRepo.save(user); // ✅ Creates: User, Post, and junction records (TagPost) // Update - add another tag const foundUser = await userRepo.findByEmail("john@example.com"); foundUser!.posts[0].addTag(new Tag({ id: Id.from("tag-3") })); await userRepo.save(foundUser!); // ✅ Only creates new junction record for tag-3 // Update - remove a post foundUser!.removePost(foundUser!.posts[0].id); await userRepo.save(foundUser!); // ✅ Deletes: Post and all its junction records (cascade) // Query with Criteria const criteria = Criteria.create() .where("email", "contains", "john") .orderBy("name", "asc") .paginate(1, 10); const results = await userRepo.find(criteria); ``` ## Generate from Schema (CLI) Auto-generate all domain code from your Prisma schema: ```bash theme={null} npx rich-domain generate ``` This creates: * ✅ Aggregates/Entities * ✅ Value Objects * ✅ Repository interfaces * ✅ Repository implementations * ✅ Mappers (ToDomain & ToPersistence) The CLI analyzes your Prisma schema relationships and generates proper DDD aggregates. See the [CLI documentation](/CLI) for more details. ## Transactional Outbox (optional) If your aggregates emit domain events, pass an optional **`outboxStore`** as the fifth argument to `PrismaRepository`'s constructor. When set, `save()` persists uncommitted events to the outbox table in the **same transaction** as the aggregate — so events are not lost if the process crashes before `dispatchAll()`. See **[Transactional Outbox](/integrations/outbox)** for the full setup (Prisma model, event bus decorator, background publisher). Repository wiring is documented under [Prisma Integration → Transactional Outbox](/integrations/prisma#transactional-outbox). ## Next Steps Deep dive into Prisma adapter features Guaranteed domain event delivery with the outbox pattern Learn advanced repository patterns Understand how changes are tracked Complete CLI documentation # With TypeORM Source: https://woltz.mintlify.app/quickstart/q-typeorm Complete guide to using Rich Domain with TypeORM ## Installation Install the TypeORM adapter: ```bash npm theme={null} npm install @woltz/rich-domain @woltz/rich-domain-typeorm typeorm reflect-metadata ``` ```bash pnpm theme={null} pnpm add @woltz/rich-domain @woltz/rich-domain-typeorm typeorm reflect-metadata ``` ```bash yarn theme={null} yarn add @woltz/rich-domain @woltz/rich-domain-typeorm typeorm reflect-metadata ``` ## Setup TypeORM Create `src/data-source.ts`: ```typescript theme={null} import { DataSource } from "typeorm"; import "reflect-metadata"; export const AppDataSource = new DataSource({ type: "postgres", host: "localhost", port: 5432, username: "user", password: "password", database: "mydb", synchronize: true, logging: false, entities: ["src/infrastructure/entities/**/*.ts"], migrations: [], subscribers: [], }); ``` Initialize in your app: ```typescript theme={null} import { AppDataSource } from "./data-source"; await AppDataSource.initialize(); console.log("Data Source initialized"); ``` ## Define Entity Schema Create your TypeORM entity: ```typescript theme={null} import { Entity, PrimaryGeneratedColumn, Column } from "typeorm"; @Entity("products") export class ProductEntity { @PrimaryGeneratedColumn("uuid") id!: string; @Column() name!: string; @Column("decimal") price!: number; @Column("int") stock!: number; @Column() status!: string; @Column() createdAt!: Date; @Column() updatedAt!: Date; } ``` ## Define Your Aggregate Create your domain model: ```typescript theme={null} import { z } from "zod"; import { Aggregate, EntityValidation, Id } from "@woltz/rich-domain"; const productSchema = z.object({ id: z.custom((val) => val instanceof Id), name: z.string().min(3), price: z.number().positive(), stock: z.number().int().min(0), status: z.enum(["active", "inactive"]), }); type ProductProps = z.infer; class Product extends Aggregate { protected static validation: EntityValidation = { schema: productSchema, config: { onCreate: true, onUpdate: true, throwOnError: true, }, }; get name() { return this.props.name; } get price() { return this.props.price; } get stock() { return this.props.stock; } updatePrice(newPrice: number) { this.props.price = newPrice; } decreaseStock(amount: number) { this.props.stock -= amount; } activate() { this.props.status = "active"; } deactivate() { this.props.status = "inactive"; } } ``` ## Create Mappers ### ToDomain Mapper Maps from TypeORM to Domain: ```typescript theme={null} import { Mapper, Id } from "@woltz/rich-domain"; export class ProductToDomainMapper extends Mapper { build(entity: ProductEntity): Product { return new Product({ id: Id.from(entity.id), name: entity.name, price: entity.price, stock: entity.stock, status: entity.status as "active" | "inactive", }); } } ``` ### ToPersistence Mapper Maps from Domain to TypeORM using `TypeORMToPersistence`: ```typescript theme={null} import { EntityManager } from "typeorm"; import { TypeORMToPersistence, TypeORMUnitOfWork, Transactional } from "@woltz/rich-domain-typeorm"; import { EntitySchemaRegistry } from "@woltz/rich-domain"; export class ProductToPersistenceMapper extends TypeORMToPersistence { protected readonly registry = new EntitySchemaRegistry().register({ entity: "Product", table: "products", }); protected readonly entityClasses = new Map any>([ ["Product", ProductEntity], ]); @Transactional() protected async onCreate(product: Product, em: EntityManager): Promise { const entity = new ProductEntity(); entity.id = product.id.value; entity.name = product.name; entity.price = product.price; entity.stock = product.stock; entity.status = product.status; entity.createdAt = new Date(); entity.updatedAt = new Date(); await em.save(entity); } } ``` * `onCreate` receives `EntityManager` as second parameter * Use `@Transactional()` decorator for automatic transaction management * `entityClasses` Map is required for the BatchExecutor * `onUpdate` is handled automatically by `TypeORMBatchExecutor` - no need to override ## Create Repository Implement the repository: ```typescript theme={null} import { TypeORMRepository, TypeORMUnitOfWork, SearchableField } from "@woltz/rich-domain-typeorm"; import { AppDataSource } from "./data-source"; import { Repository } from "typeorm"; const uow = new TypeORMUnitOfWork(AppDataSource); interface IProductRepository { save(product: Product): Promise; findById(id: string): Promise; } class ProductRepository extends TypeORMRepository implements IProductRepository { constructor() { const typeormRepo = AppDataSource.getRepository(ProductEntity); super({ typeormRepository: typeormRepo, toDomainMapper: new ProductToDomainMapper(), toPersistenceMapper: new ProductToPersistenceMapper(uow), uow, alias: "product", // Optional: alias for QueryBuilder }); } protected getSearchableFields(): SearchableField[] { return ["name"]; // Case-insensitive search by default } protected getDefaultRelations(): string[] { return []; // Add relations if needed: ["category", "supplier"] } async findById(id: string): Promise { const entity = await this.typeormRepo.findOne({ where: { id }, relations: this.getDefaultRelations(), }); return entity ? this.toDomainMapper.build(entity) : null; } } ``` **Key points:** * ToPersistenceMapper receives `uow` in constructor * Use `getSearchableFields()` to define which fields support search * Use `getDefaultRelations()` to eagerly load relations * `typeormRepo` is available from the base class for custom queries ```` ## Use It ```typescript // Initialize TypeORM await AppDataSource.initialize(); const repository = new ProductRepository(); // Create product const product = new Product({ name: "Mechanical Keyboard", price: 149.99, stock: 50, status: "active", }); // Save to database await repository.save(product); console.log("Product created:", product.id.value); // Update product.updatePrice(129.99); product.decreaseStock(5); await repository.save(product); // Query const found = await repository.findById(product.id.value); console.log("Found product:", found?.name); // "Mechanical Keyboard" // Use Criteria for complex queries const criteria = Criteria.create() .where("status", "equals", "active") .where("price", "greaterThan", 100) .orderBy("name", "asc") .paginate(1, 10); const results = await repository.find(criteria); console.log(results.data); // Array of products console.log(results.meta); // Pagination info ```` ## Working with Relations ### 1:N Owned Relations ```typescript theme={null} // TypeORM Entities @Entity("users") export class UserEntity { @PrimaryGeneratedColumn("uuid") id!: string; @Column() email!: string; @Column() name!: string; @OneToMany(() => PostEntity, post => post.author) posts!: PostEntity[]; } @Entity("posts") export class PostEntity { @PrimaryGeneratedColumn("uuid") id!: string; @Column() authorId!: string; @ManyToOne(() => UserEntity, user => user.posts) author!: UserEntity; } // ToPersistence with 1:N export class UserToPersistenceMapper extends TypeORMToPersistence { protected readonly registry = new EntitySchemaRegistry() .register({ entity: "User", table: "user", collections: { posts: { type: "owned", entity: "Post", }, }, }) .register({ entity: "Post", table: "post", parentFk: { field: "authorId", parentEntity: "User", }, }); protected readonly entityClasses = new Map([ ["User", UserEntity], ["Post", PostEntity], ]); @Transactional() protected async onCreate(user: User, em: EntityManager): Promise { // Create user const userEntity = new UserEntity(); userEntity.id = user.id.value; userEntity.email = user.email; userEntity.name = user.name; await em.save(userEntity); // Create owned posts for (const post of user.posts) { const postEntity = new PostEntity(); postEntity.id = post.id.value; postEntity.title = post.title; postEntity.authorId = user.id.value; await em.save(postEntity); } } } ``` ### N:N with Junction Tables ```typescript theme={null} // TypeORM Entities @Entity("posts") export class PostEntity { @PrimaryGeneratedColumn("uuid") id!: string; @ManyToMany(() => TagEntity) @JoinTable({ name: "_PostToTag", joinColumn: { name: "A" }, inverseJoinColumn: { name: "B" }, }) tags!: TagEntity[]; } @Entity("tags") export class TagEntity { @PrimaryGeneratedColumn("uuid") id!: string; @Column() name!: string; } // ToPersistence with N:N export class PostToPersistenceMapper extends TypeORMToPersistence { protected readonly registry = new EntitySchemaRegistry().register({ entity: "Post", table: "post", collections: { tags: { type: "reference", entity: "Tag", junction: { table: "_PostToTag", sourceKey: "A", targetKey: "B", }, }, }, }); protected readonly entityClasses = new Map([ ["Post", PostEntity], ["Tag", TagEntity], ]); @Transactional() protected async onCreate(post: Post, em: EntityManager): Promise { // Create post const entity = new PostEntity(); entity.id = post.id.value; entity.title = post.title; entity.content = post.content; await em.save(entity); // Create junction records if (post.tags.length > 0) { for (const tag of post.tags) { await em.query( `INSERT INTO "_PostToTag" ("A", "B") VALUES ($1, $2) ON CONFLICT DO NOTHING`, [entity.id, tag.id.value] ); } } } } // Usage const post = Post.create({ title: "My Post", content: "Content here", }); const tag1 = new Tag({ id: Id.from("tag-1") }); const tag2 = new Tag({ id: Id.from("tag-2") }); post.addTag(tag1); post.addTag(tag2); await postRepository.save(post); // Post and junction records are created automatically ``` ## Unit of Work with Transactions Use the `@Transactional` decorator for automatic transaction management: ```typescript theme={null} import { Transactional } from "@woltz/rich-domain-typeorm"; class ProductService { constructor(private repository: ProductRepository) {} @Transactional() async transferStock(fromId: string, toId: string, amount: number) { const from = await this.repository.findById(fromId); const to = await this.repository.findById(toId); if (!from || !to) throw new Error("Product not found"); from.decreaseStock(amount); to.increaseStock(amount); await this.repository.save(from); await this.repository.save(to); // Commits on success, rolls back on error } } ``` Or use the imperative API: ```typescript theme={null} async function transferStock(fromId: string, toId: string, amount: number) { await uow.transaction(async () => { const from = await repository.findById(fromId); const to = await repository.findById(toId); if (!from || !to) throw new Error("Product not found"); from.decreaseStock(amount); to.increaseStock(amount); await repository.save(from); await repository.save(to); }); } ``` ## Advanced Queries TypeORM gives you full access to QueryBuilder: ```typescript theme={null} class ProductRepository extends TypeORMRepository { async findLowStock(threshold: number): Promise { const entities = await this.repository .createQueryBuilder("product") .where("product.stock < :threshold", { threshold }) .andWhere("product.status = :status", { status: "active" }) .orderBy("product.stock", "ASC") .getMany(); return entities.map(e => this.toDomainMapper.map(e)); } async findByPriceRange(min: number, max: number): Promise { const entities = await this.repository .createQueryBuilder("product") .where("product.price BETWEEN :min AND :max", { min, max }) .getMany(); return entities.map(e => this.toDomainMapper.map(e)); } } ``` ## Transactional Outbox (optional) If your aggregates emit domain events, pass an optional **`outboxStore`** in `TypeORMRepositoryConfig`. When set, `save()` persists uncommitted events to the outbox table in the **same transaction** as the aggregate — so events are not lost if the process crashes before `dispatchAll()`. See **[Transactional Outbox](/integrations/outbox)** for the full setup (`OutboxEntity`, event bus decorator, background publisher). Repository wiring is documented under [TypeORM Integration → Transactional Outbox](/integrations/typeorm#transactional-outbox). ## Next Steps Deep dive into TypeORM adapter features Guaranteed domain event delivery with the outbox pattern Learn advanced repository patterns Understand how changes are tracked Advanced transaction management # Mappers Source: https://woltz.mintlify.app/repository/mappers Transform data between domain models and persistence layer ## Overview Mappers are responsible for transforming data between your domain model (Entities, Aggregates, Value Objects) and your persistence layer (database records). They keep your domain clean and independent of database concerns. ```typescript theme={null} // Domain → Database const record = userToPersistenceMapper.build(user); // Database → Domain const user = userToDomainMapper.build(record); ``` ## Why Use Mappers? Domain models stay focused on business logic, not database schema Change database schema without touching domain code Handle type conversions, naming conventions, and structure differences Test domain logic without database dependencies ## The Mapper Base Class ```typescript theme={null} import { Mapper } from "@woltz/rich-domain"; abstract class Mapper { abstract build(input: Input, ...args: unknown[]): Output; buildMany(inputs: Input[]): Output[] { return inputs.map((input) => this.build(input)); } } ``` The base class is simple - implement `build()` to transform from Input to Output. Use `buildMany()` to transform arrays. ## Creating Mappers ### Domain to Persistence Transform domain entities to database records: ```typescript theme={null} interface UserRecord { id: string; user_name: string; user_email: string; status: string; is_verified: boolean; created_at: Date; updated_at: Date; } class UserToPersistenceMapper extends Mapper { build(user: User): UserRecord { return { id: user.id.value, // Id → string user_name: user.name, // camelCase → snake_case user_email: user.email, status: user.status, is_verified: user.isVerified, created_at: new Date(), updated_at: new Date(), }; } } ``` ### Persistence to Domain Transform database records to domain entities: ```typescript theme={null} class UserToDomainMapper extends Mapper { build(record: UserRecord): User { return new User({ id: Id.from(record.id), // string → Id name: record.user_name, // snake_case → camelCase email: record.user_email, status: record.status as "active" | "inactive", isVerified: record.is_verified, }); } } ``` ## Handling Complex Structures ### Nested Entities ```typescript theme={null} interface OrderRecord { id: string; customer_id: string; status: string; items: OrderItemRecord[]; } interface OrderItemRecord { id: string; order_id: string; product_id: string; quantity: number; unit_price: number; } class OrderToDomainMapper extends Mapper { constructor(private itemMapper: OrderItemToDomainMapper) { super(); } build(record: OrderRecord): Order { return new Order({ id: Id.from(record.id), customerId: record.customer_id, status: record.status as OrderStatus, items: record.items.map((item) => this.itemMapper.build(item)), }); } } class OrderItemToDomainMapper extends Mapper { build(record: OrderItemRecord): OrderItem { return new OrderItem({ id: Id.from(record.id), productId: record.product_id, quantity: record.quantity, unitPrice: record.unit_price, }); } } ``` ### Value Objects ```typescript theme={null} interface UserRecord { id: string; name: string; // Address is flattened in database address_street: string | null; address_city: string | null; address_zip: string | null; } class UserToDomainMapper extends Mapper { build(record: UserRecord): User { // Reconstruct Value Object from flat fields const address = record.address_street ? new Address({ street: record.address_street, city: record.address_city!, zipCode: record.address_zip!, }) : null; return new User({ id: Id.from(record.id), name: record.name, address, }); } } class UserToPersistenceMapper extends Mapper { build(user: User): UserRecord { return { id: user.id.value, name: user.name, // Flatten Value Object address_street: user.address?.street ?? null, address_city: user.address?.city ?? null, address_zip: user.address?.zipCode ?? null, }; } } ``` ### Related Entities (Separate Tables) When nested entities are stored in separate tables: ```typescript theme={null} // For reading - includes loaded relations interface UserWithPostsRecord { id: string; name: string; posts: PostRecord[]; } class UserWithPostsToDomainMapper extends Mapper { constructor(private postMapper: PostToDomainMapper) { super(); } build(record: UserWithPostsRecord): User { return new User({ id: Id.from(record.id), name: record.name, posts: record.posts.map((p) => this.postMapper.build(p)), }); } } // For writing - only the user data (posts saved separately) class UserToPersistenceMapper extends Mapper { build(user: User): UserRecord { return { id: user.id.value, name: user.name, // Posts are NOT included - they're saved separately }; } } ``` ## Type Conversions Common conversions between domain and persistence: ### Id ↔ String ```typescript theme={null} // Domain → Persistence id: user.id.value // Id → string // Persistence → Domain id: Id.from(record.id) // string → Id ``` ### Enum ↔ String ```typescript theme={null} // Domain type OrderStatus = "draft" | "confirmed" | "shipped" | "delivered"; // Domain → Persistence status: order.status // Already a string // Persistence → Domain status: record.status as OrderStatus // Cast to union type ``` ### Date Handling ```typescript theme={null} // Domain → Persistence createdAt: user.createdAt // Date stays as Date for most ORMs // Persistence → Domain createdAt: new Date(record.created_at) // If stored as string/timestamp ``` ### JSON Fields ```typescript theme={null} // Domain interface UserSettings { theme: "light" | "dark"; notifications: boolean; } // Domain → Persistence (JSON column) settings: JSON.stringify(user.settings) // Persistence → Domain settings: JSON.parse(record.settings) as UserSettings ``` ### Nullable Relations ```typescript theme={null} // Domain → Persistence addressId: user.address?.id.value ?? null // Persistence → Domain (load address in repository query, not in mapper) address: record.address ? new Address({ street: record.address.street, city: record.address.city }) : null ``` ## Mapper Composition Compose mappers for complex aggregates: ```typescript theme={null} class AggregateMappers { constructor( readonly userToDomain: UserToDomainMapper, readonly userToPersistence: UserToPersistenceMapper, readonly postToDomain: PostToDomainMapper, readonly postToPersistence: PostToPersistenceMapper, readonly commentToDomain: CommentToDomainMapper, readonly commentToPersistence: CommentToPersistenceMapper ) {} } // Create all mappers together const mappers = new AggregateMappers( new UserToDomainMapper(), new UserToPersistenceMapper(), new PostToDomainMapper(), new PostToPersistenceMapper(), new CommentToDomainMapper(), new CommentToPersistenceMapper() ); // Use in repository class UserRepository extends Repository { constructor( private db: Database, private mappers: AggregateMappers ) { super(); } protected get mapperToDomain() { return this.mappers.userToDomain; } protected get mapperToPersistence() { return this.mappers.userToPersistence; } } ``` ## Mapping Lists The base `Mapper` class includes a `buildMany()` method for transforming arrays: ```typescript theme={null} class UserToDomainMapper extends Mapper { build(record: UserRecord): User { return new User({ id: Id.from(record.id), name: record.name, }); } } // Usage - buildMany is inherited from Mapper const users = userMapper.buildMany(records); ``` ## Using with Repository ```typescript theme={null} class UserRepository extends Repository { constructor( private db: PrismaClient, protected readonly mapperToDomain: UserToDomainMapper, protected readonly mapperToPersistence: UserToPersistenceMapper ) { super(); } async findById(id: string): Promise { const record = await this.db.user.findUnique({ where: { id }, include: { posts: true }, }); return record ? this.mapperToDomain.build(record) : null; } async save(user: User): Promise { const data = this.mapperToPersistence.build(user); if (user.isNew()) { await this.db.user.create({ data }); } else { await this.db.user.update({ where: { id: user.id.value }, data, }); } } } ``` ## Testing Mappers ```typescript theme={null} describe("UserToDomainMapper", () => { const mapper = new UserToDomainMapper(); it("should map record to domain", () => { const record: UserRecord = { id: "user-123", user_name: "John Doe", user_email: "john@example.com", status: "active", is_verified: true, created_at: new Date(), updated_at: new Date(), }; const user = mapper.build(record); expect(user).toBeInstanceOf(User); expect(user.id.value).toBe("user-123"); expect(user.name).toBe("John Doe"); expect(user.email).toBe("john@example.com"); expect(user.status).toBe("active"); expect(user.isVerified).toBe(true); }); it("should handle null address", () => { const record: UserRecord = { id: "user-123", user_name: "John", address_street: null, address_city: null, address_zip: null, }; const user = mapper.build(record); expect(user.address).toBeNull(); }); }); describe("UserToPersistenceMapper", () => { const mapper = new UserToPersistenceMapper(); it("should map domain to record", () => { const user = new User({ id: Id.from("user-123"), name: "John Doe", email: "john@example.com", status: "active", isVerified: true, }); const record = mapper.build(user); expect(record.id).toBe("user-123"); expect(record.user_name).toBe("John Doe"); expect(record.user_email).toBe("john@example.com"); }); }); ``` # Overview Source: https://woltz.mintlify.app/repository/overview Abstract persistence layer for domain aggregates ## What is a Repository? A Repository is an abstraction that mediates between the domain layer and the data mapping layer. It provides a collection-like interface for accessing domain aggregates while hiding the complexity of database operations. ```typescript theme={null} // Repository abstracts away persistence details const user = await userRepository.findById("user-123"); user.changeName("New Name"); await userRepository.save(user); // The domain code doesn't know about: // - Database connections // - SQL/ORM queries // - Data mapping // - Which database is being used ``` ## Why Use Repositories? Domain code stays free of persistence concerns Easy to mock or use in-memory implementations for testing Switch databases or ORMs without changing domain code Centralized place for query logic and data access patterns ## Repository Types The library provides several base classes for different use cases: ### Repository (Full CRUD) Complete read and write operations: ```typescript theme={null} import { Repository, Aggregate, Criteria, PaginatedResult } from "@woltz/rich-domain"; abstract class Repository> { abstract find(criteria?: Criteria): Promise>; abstract findById(id: string): Promise; abstract save(entity: TDomain): Promise; abstract delete(entity: TDomain): Promise; abstract count(criteria?: Criteria): Promise; abstract exists(id: string): Promise; } ``` ### ReadRepository Read-only operations (useful for CQRS read models): ```typescript theme={null} abstract class ReadRepository> { abstract find(criteria?: Criteria): Promise>; abstract findById(id: string): Promise; abstract count(criteria?: Criteria): Promise; abstract exists(id: string): Promise; } ``` ### WriteRepository Write-only operations: ```typescript theme={null} abstract class WriteRepository> { abstract save(entity: TDomain): Promise; abstract delete(entity: TDomain): Promise; } ``` ## Implementing a Repository ### Step 1: Define Your Aggregate ```typescript theme={null} import { Aggregate, Id } from "@woltz/rich-domain"; interface UserProps { id: Id; name: string; email: string; status: "active" | "inactive"; } class User extends Aggregate { get name() { return this.props.name; } get email() { return this.props.email; } get status() { return this.props.status; } changeName(name: string) { this.props.name = name; } activate() { this.props.status = "active"; } } ``` ### Step 2: Create Mappers Mappers transform data between domain models and persistence format: ```typescript theme={null} import { Mapper, Id } from "@woltz/rich-domain"; // Domain → Persistence class UserToPersistenceMapper extends Mapper { build(user: User): UserRecord { return { id: user.id.value, name: user.name, email: user.email, status: user.status, created_at: new Date(), updated_at: new Date(), }; } } // Persistence → Domain class UserToDomainMapper extends Mapper { build(record: UserRecord): User { return new User({ id: Id.from(record.id), name: record.name, email: record.email, status: record.status as "active" | "inactive", }); } } ``` ### Step 3: Implement Repository Here's a database-agnostic implementation skeleton: ```typescript theme={null} import { Repository, Criteria, PaginatedResult } from "@woltz/rich-domain"; class UserRepository extends Repository { constructor( private db: YourDatabaseClient, // Any database client private mapperToDomain: UserToDomainMapper, private mapperToPersistence: UserToPersistenceMapper ) { super(); } async findById(id: string): Promise { // Implement using your database client const record = await this.db.findOne("users", { id }); return record ? this.mapperToDomain.build(record) : null; } async find(criteria: Criteria): Promise> { // Convert criteria to your database's query format const query = this.buildQuery(criteria); const pagination = criteria.getPagination(); const [records, total] = await Promise.all([ this.db.query("users", query, pagination), this.db.count("users", query), ]); const users = records.map((r) => this.mapperToDomain.build(r)); return PaginatedResult.create(users, pagination, total); } async save(user: User): Promise { const data = this.mapperToPersistence.build(user); if (user.isNew()) { await this.db.insert("users", data); } else { await this.db.update("users", user.id.value, data); } } async delete(user: User): Promise { await this.db.delete("users", user.id.value); } async exists(id: string): Promise { return this.db.exists("users", { id }); } async count(criteria?: Criteria): Promise { const query = criteria ? this.buildQuery(criteria) : {}; return this.db.count("users", query); } private buildQuery(criteria: Criteria): object { // Convert criteria filters/orders to your database format // This is ORM/database-specific } } ``` For complete implementations with specific ORMs, see: * [Prisma Integration](/integrations/prisma) - Full-featured adapter with change tracking * [TypeORM Integration](/integrations/typeorm) - Full-featured adapter with change tracking * [Drizzle Integration](/integrations/drizzle) - Coming soon ## Using the Repository ### Finding Entities ```typescript theme={null} // Find by ID const user = await userRepository.findById("user-123"); // Find with criteria const activeUsers = await userRepository.find( Criteria.create() .whereEquals("status", "active") .orderByDesc("createdAt") .paginate(1, 20) ); // Check existence const exists = await userRepository.exists("user-123"); // Count const count = await userRepository.count( Criteria.create().whereEquals("status", "active") ); ``` ### Saving Entities ```typescript theme={null} // Create new entity const user = new User({ name: "John Doe", email: "john@example.com", status: "active", }); await userRepository.save(user); // INSERT // Update existing entity const user = await userRepository.findById("user-123"); user.changeName("Jane Doe"); await userRepository.save(user); // UPDATE ``` ### Deleting Entities ```typescript theme={null} const user = await userRepository.findById("user-123"); await userRepository.delete(user); ``` ## Custom Query Methods Add domain-specific query methods to your repository: ```typescript theme={null} class UserRepository extends Repository { // ... base implementation async findByEmail(email: string): Promise { const record = await this.db.findOne("users", { email }); return record ? this.mapperToDomain.build(record) : null; } async findActiveUsers(page: number, limit: number): Promise> { return this.find( Criteria.create() .whereEquals("status", "active") .orderByDesc("createdAt") .paginate(page, limit) ); } async findRecentlyJoined(days: number): Promise { const since = new Date(); since.setDate(since.getDate() - days); const result = await this.find( Criteria.create() .where("createdAt", "greaterThan", since) .orderByDesc("createdAt") ); return result.data; } async countByStatus(status: "active" | "inactive"): Promise { return this.count( Criteria.create().whereEquals("status", status) ); } } ``` ## Repository with Change Tracking For aggregates with complex nested structures, use change tracking to efficiently persist only what changed: ```typescript theme={null} class OrderRepository extends Repository { async save(order: Order): Promise { const changes = order.getChanges(); // Skip if no changes if (changes.isEmpty() && !order.isNew()) { return; } // Use transaction for consistency await this.db.transaction(async (tx) => { if (order.isNew()) { // Create root aggregate await tx.insert("orders", this.mapOrder(order)); } // Process batch operations in correct order const batch = changes.toBatchOperations(); // 1. Deletes (leaf → root for FK constraints) for (const del of batch.deletes) { await tx.deleteMany(del.entity, { id: { in: del.ids } }); } // 2. Creates (root → leaf for FK constraints) for (const create of batch.creates) { await tx.insertMany( create.entity, create.items.map((item) => this.mapItem(item)) ); } // 3. Updates for (const update of batch.updates) { for (const item of update.items) { await tx.update(update.entity, item.id, item.changedFields); } } }); // Mark aggregate as clean after successful save order.markAsClean(); } } ``` The [Prisma Adapter](/integrations/prisma) provides `PrismaBatchExecutor` that handles all this automatically. ## Testing with Repositories ### In-Memory Implementation Create an in-memory implementation for unit tests: ```typescript theme={null} class InMemoryUserRepository extends Repository { private store = new Map(); async findById(id: string): Promise { return this.store.get(id) || null; } async save(user: User): Promise { this.store.set(user.id.value, user); } async delete(user: User): Promise { this.store.delete(user.id.value); } async find(criteria: Criteria): Promise> { const all = Array.from(this.store.values()); // PaginatedResult.fromArray applies criteria in-memory return PaginatedResult.fromArray(all, criteria); } async exists(id: string): Promise { return this.store.has(id); } async count(criteria?: Criteria): Promise { if (!criteria) return this.store.size; const result = await this.find(criteria); return result.meta.total; } // Test helpers clear(): void { this.store.clear(); } getAll(): User[] { return Array.from(this.store.values()); } } ``` ### Using in Tests ```typescript theme={null} describe("UserService", () => { let userRepository: InMemoryUserRepository; let userService: UserService; beforeEach(() => { userRepository = new InMemoryUserRepository(); userService = new UserService(userRepository); }); afterEach(() => { userRepository.clear(); }); it("should create a user", async () => { const user = await userService.createUser({ name: "John", email: "john@example.com", }); expect(user.id).toBeDefined(); expect(await userRepository.exists(user.id.value)).toBe(true); }); it("should find active users", async () => { // Setup await userRepository.save(new User({ name: "Active", status: "active" })); await userRepository.save(new User({ name: "Inactive", status: "inactive" })); // Test const result = await userRepository.find( Criteria.create().whereEquals("status", "active") ); expect(result.data.length).toBe(1); expect(result.data[0].name).toBe("Active"); }); }); ``` ## Next Steps Learn about type-safe query building Domain ↔ persistence data transformation Map entities to database tables Complete Prisma implementation # PaginatedResult Source: https://woltz.mintlify.app/repository/paginated-result Working with paginated query results and deep serialization ## Overview `PaginatedResult` wraps query results with pagination metadata. It provides a consistent structure for paginated data and handles deep serialization of entities and value objects. ```typescript theme={null} const result = await userRepository.find(criteria); // Access data and metadata result.data; // User[] - the actual entities result.meta.page; // 1 result.meta.limit; // 20 result.meta.total; // 150 result.meta.totalPages; // 8 result.meta.hasNext; // true result.meta.hasPrevious; // false ``` ## Creating PaginatedResult ### From Repository Most commonly, you'll get `PaginatedResult` from repository methods: ```typescript theme={null} const criteria = Criteria.create() .whereEquals("status", "active") .orderByDesc("createdAt") .paginate(1, 20); const result = await userRepository.find(criteria); // PaginatedResult ``` ### Manual Creation Create a `PaginatedResult` directly: ```typescript theme={null} import { PaginatedResult } from "@woltz/rich-domain"; const users: User[] = [...]; // Your data const pagination = { page: 1, limit: 20, offset: 0 }; const total = 150; const result = PaginatedResult.create(users, pagination, total); ``` ### From Array (Testing/In-Memory) Apply criteria to an in-memory array: ```typescript theme={null} const allUsers: User[] = [...]; // All users in memory const criteria = Criteria.create() .whereEquals("status", "active") .orderByDesc("createdAt") .paginate(1, 20); const result = PaginatedResult.fromArray(allUsers, criteria); // Filters, orders, and paginates the array ``` `fromArray()` is useful for testing or when working with small datasets. For large datasets, always use database-level filtering. ## Pagination Metadata ### PaginationMeta Structure ```typescript theme={null} interface PaginationMeta { page: number; // Current page (1-based) limit: number; // Items per page total: number; // Total matching records totalPages: number; // Total number of pages hasNext: boolean; // Has next page hasPrevious: boolean; // Has previous page } ``` ### Accessing Metadata ```typescript theme={null} const result = await userRepository.find(criteria); console.log(result.meta); // { // page: 1, // limit: 20, // total: 150, // totalPages: 8, // hasNext: true, // hasPrevious: false // } // Individual properties result.meta.page; // 1 result.meta.limit; // 20 result.meta.total; // 150 result.meta.totalPages; // 8 result.meta.hasNext; // true result.meta.hasPrevious; // false ``` ### Creating Metadata Manually ```typescript theme={null} const pagination = { page: 2, limit: 10, offset: 10 }; const total = 45; const meta = PaginatedResult.createMeta(pagination, total); // { // page: 2, // limit: 10, // total: 45, // totalPages: 5, // hasNext: true, // hasPrevious: true // } ``` ## Utility Properties ### isEmpty Check if result has no data: ```typescript theme={null} const result = await userRepository.find(criteria); if (result.isEmpty) { console.log("No users found"); } ``` ### hasMore Check if there are more pages: ```typescript theme={null} const result = await userRepository.find(criteria); if (result.hasMore) { // Show "Load More" button } // Same as result.meta.hasNext ``` ## Transforming Results ### map() Transform each item in the result: ```typescript theme={null} const users = await userRepository.find(criteria); // Transform to DTOs const userDtos = users.map((user) => ({ id: user.id.value, displayName: `${user.firstName} ${user.lastName}`, email: user.email, avatarUrl: user.profile?.avatarUrl || "/default-avatar.png", })); // userDtos is PaginatedResult // Metadata is preserved ``` ### Chaining map() ```typescript theme={null} const result = await orderRepository.find(criteria); const summaries = result .map((order) => ({ id: order.id.value, total: order.total, itemCount: order.items.length, customer: order.customer.name, })) .map((summary) => ({ ...summary, formattedTotal: `$${summary.total.toFixed(2)}`, })); ``` ## Serialization ### toJSON() Deep serialize all entities, value objects, and IDs: ```typescript theme={null} const result = await userRepository.find(criteria); const json = result.toJSON(); // { // data: [ // { // id: "user-123", // Id serialized to string // name: "John Doe", // email: "john@example.com", // address: { // Value Object serialized // street: "123 Main St", // city: "New York" // }, // posts: [ // Nested entities serialized // { id: "post-1", title: "Hello World" }, // { id: "post-2", title: "Another Post" } // ] // }, // // ... more users // ], // meta: { // page: 1, // limit: 20, // total: 150, // totalPages: 8, // hasNext: true, // hasPrevious: false // } // } ``` ### What Gets Serialized | Type | Serialization | | -------------- | --------------------------------- | | `Id` | String value (`id.value`) | | `Entity` | Calls `toJson()` | | `Aggregate` | Calls `toJson()` | | `ValueObject` | Calls `toJson()` | | `Date` | ISO string | | `Array` | Maps items recursively | | `Plain Object` | Serializes properties recursively | | `Primitives` | As-is | ### Type Safety The return type reflects serialization: ```typescript theme={null} type PaginatedJsonResult = { data: InferJsonResult[]; // Serialized data meta: PaginationMeta; }; // InferJsonResult transforms: // - Entities → their toJson() return type // - Other types → as-is ``` ## API Response Pattern ```typescript theme={null} // Express.js app.get("/users", async (req, res) => { const criteria = Criteria.fromQueryParams(req.query); const result = await userRepository.find(criteria); // toJSON() for API response res.json(result.toJSON()); }); // Fastify fastify.get("/users", async (request, reply) => { const criteria = Criteria.fromQueryParams(request.query); const result = await userRepository.find(criteria); return result.toJSON(); }); ``` ## Pagination UI Example ```typescript theme={null} // Backend const result = await userRepository.find(criteria); const response = result.toJSON(); // Frontend - React example function UserList({ data, meta }: PaginatedJsonResult) { return (
{/* Data */}
    {data.map((user) => (
  • {user.name}
  • ))}
{/* Pagination */}
Page {meta.page} of {meta.totalPages}
{/* Info */}

Showing {data.length} of {meta.total} users

); } ``` ## Complete Example ```typescript theme={null} // Repository implementation class UserRepository extends Repository { async findActiveUsers( page: number, limit: number, search?: string ): Promise> { let criteria = Criteria.create() .whereEquals("status", "active") .whereNotNull("emailVerifiedAt") .orderByDesc("createdAt") .paginate(page, limit); if (search) { criteria = criteria.search(["name", "email"], search); } return this.find(criteria); } } // API endpoint app.get("/users", async (req, res) => { const { page = 1, limit = 20, search } = req.query; const result = await userRepository.findActiveUsers( Number(page), Number(limit), search as string ); // Check if empty if (result.isEmpty) { return res.json({ data: [], meta: result.meta, message: "No users found", }); } // Transform for API const response = result .map((user) => ({ id: user.id.value, name: user.name, email: user.email, avatar: user.profile?.avatarUrl, joinedAt: user.createdAt.toISOString(), })) .toJSON(); res.json(response); }); ``` ## fromArray() Behavior When using `fromArray()`, the criteria is fully applied in memory: ```typescript theme={null} const allProducts: Product[] = [...]; // 1000 products const criteria = Criteria.create() .whereEquals("category", "electronics") // Filter .where("price", "lessThan", 500) // Filter .search(["name"], "laptop") // Search .orderByDesc("rating") // Order .paginate(1, 20); // Paginate const result = PaginatedResult.fromArray(allProducts, criteria); // Process: // 1. Apply search (finds matches across all items) // 2. Apply filters (reduces to matching items) // 3. Apply ordering // 4. Calculate total from filtered results // 5. Apply pagination (slice) // 6. Return PaginatedResult with correct metadata ``` `fromArray()` loads all data into memory before filtering. Only use it for small datasets or testing. For production, implement filtering at the database level. ## API Reference ### Static Methods | Method | Parameters | Returns | Description | | ------------ | -------------------------------------------------- | -------------------- | ----------------------------- | | `create` | `data: T[], pagination: Pagination, total: number` | `PaginatedResult` | Create with explicit metadata | | `createMeta` | `pagination: Pagination, total: number` | `PaginationMeta` | Create metadata only | | `fromArray` | `items: T[], criteria: Criteria` | `PaginatedResult` | Apply criteria to array | ### Instance Properties | Property | Type | Description | | --------- | ---------------- | ------------------------ | | `data` | `T[]` | The result items | | `meta` | `PaginationMeta` | Pagination metadata | | `isEmpty` | `boolean` | True if no data | | `hasMore` | `boolean` | True if more pages exist | ### Instance Methods | Method | Parameters | Returns | Description | | -------- | -------------------- | ------------------------ | --------------- | | `toJSON` | - | `PaginatedJsonResult` | Deep serialize | | `map` | `fn: (item: T) => U` | `PaginatedResult` | Transform items | # Schema Registry Source: https://woltz.mintlify.app/repository/schema-registry Map domain entities to database tables and fields ## Overview `EntitySchemaRegistry` provides a centralized mapping between your domain entities and database tables. It handles field name translations, foreign key relationships, and collection configurations - making your mappers cleaner and more maintainable. ```typescript theme={null} import { EntitySchemaRegistry } from "@woltz/rich-domain"; const registry = new EntitySchemaRegistry() .register({ entity: "User", table: "users", fields: { email: "user_email" }, }) .register({ entity: "Post", table: "blog_posts", fields: { content: "post_content" }, parentFk: { field: "author_id", parentEntity: "User" }, }); ``` ## Why Use Schema Registry? Define all entity-to-table mappings in one place Map camelCase domain fields to snake\_case database columns Configure parent-child relationships and foreign keys Distinguish between owned (1:N) and reference (N:N) relations ## Basic Usage ### Registering Entities ```typescript theme={null} import { EntitySchemaRegistry } from "@woltz/rich-domain"; const registry = new EntitySchemaRegistry(); // Simple entity - same field names in domain and database registry.register({ entity: "Category", table: "categories", }); // Entity with field mapping registry.register({ entity: "User", table: "users", fields: { firstName: "first_name", // domain → database lastName: "last_name", createdAt: "created_at", }, }); // Chained registration registry .register({ entity: "User", table: "users" }) .register({ entity: "Post", table: "posts" }) .register({ entity: "Comment", table: "comments" }); ``` ### Bulk Registration ```typescript theme={null} registry.registerAll([ { entity: "User", table: "users" }, { entity: "Post", table: "posts" }, { entity: "Comment", table: "comments" }, { entity: "Tag", table: "tags" }, ]); ``` ## Field Mapping Map domain property names to database column names: ```typescript theme={null} registry.register({ entity: "User", table: "users", fields: { firstName: "first_name", lastName: "last_name", emailAddress: "email", phoneNumber: "phone", createdAt: "created_at", updatedAt: "updated_at", }, }); // Get mapped field name registry.mapFieldName("User", "firstName"); // "first_name" registry.mapFieldName("User", "status"); // "status" (unchanged) // Get all field mappings registry.getFieldsMap("User"); // { firstName: "first_name", lastName: "last_name", ... } ``` ### Mapping Entity Data Transform a complete domain entity to database format: ```typescript theme={null} const user = new User({ firstName: "John", lastName: "Doe", email: "john@example.com", posts: [...], // Ignored - it's a collection }); const dbData = registry.mapEntity("User", user); // { // id: "user-123", // first_name: "John", // last_name: "Doe", // email: "john@example.com" // } ``` ### Mapping Partial Fields Transform only specific fields (useful for updates): ```typescript theme={null} const changedFields = { firstName: "Jane", updatedAt: new Date() }; const dbFields = registry.mapFields("User", changedFields); // { first_name: "Jane", updated_at: Date } ``` ## Parent-Child Relationships Configure foreign key relationships for 1:N (owned) relationships: ```typescript theme={null} registry .register({ entity: "User", table: "users", }) .register({ entity: "Post", table: "posts", parentFk: { field: "author_id", // FK column name in database parentEntity: "User", // Parent entity name }, }) .register({ entity: "Comment", table: "comments", parentFk: { field: "post_id", parentEntity: "Post", }, }); ``` ### Getting FK Information ```typescript theme={null} // Get parent entity name registry.getParentEntity("Post"); // "User" registry.getParentEntity("Comment"); // "Post" registry.getParentEntity("User"); // null (root entity) // Get FK field name registry.getParentFkField("Post"); // "author_id" registry.getParentFkField("Comment"); // "post_id" // Get FK object for insert registry.getParentFk("Post", "user-123"); // { author_id: "user-123" } registry.getParentFk("Comment", "post-456"); // { post_id: "post-456" } ``` ## Custom Primary Keys By default, the registry assumes every table uses an `id` column as the primary key. For tables that use a different PK (common in 1:1 child tables sharing the parent's identity), configure `primaryKey`. `primaryKey` is the **database column name**, not a domain property. Adapters always read the identity from `entity.id` and map it to that column. Setting `primaryKey: "factoryId"` produces `where: { factoryId: entity.id.value }` — not `{ factoryId: entity.factoryId }`. ```typescript theme={null} registry .register({ entity: "Factory", table: "factory", }) .register({ entity: "Profile", table: "factoryProfile", primaryKey: "factoryId", parentFk: { field: "factoryId", parentEntity: "Factory" }, }); // Domain: profile.id.value === factory.id.value // DB update: WHERE factoryId = profile.id.value ``` For 1:1 shared PK, assign `Profile.id` the same value as `Factory.id` when creating the child. Batch executors and repositories use `buildWhereById` / `buildWhereByIds` to resolve the correct column automatically. Composite primary keys are not supported in v1. ## Collection Configuration Configure how collections (1:N and N:N) should be handled: ```typescript theme={null} registry.register({ entity: "Post", table: "posts", parentFk: { field: "author_id", parentEntity: "User" }, collections: { // 1:N owned relationship - comments belong to post comments: { type: "owned" }, // Field name related to the relationship in the domain; // 'posts.tags' <- Domain Relation field name is 'tags' tags: { // N:N reference relationship - tags exist independently type: "reference", entity: "Tag", // Optional: use when the Prisma relation field name differs from the domain property name // e.g. domain: "tags", Prisma schema field: "post_tags" relationName: "post_tags", // Optional: junction table config (for ORMs that need it) junction: { table: "post_tags", sourceKey: "post_id", targetKey: "tag_id", }, }, }, }); ``` ### Collection Types | Type | Relationship | Behavior | | ----------- | ------------ | ------------------------------------------------ | | `owned` | 1:N | Children are created/deleted with parent | | `reference` | N:N | Only links are created/removed, entities persist | ### Checking Collection Types ```typescript theme={null} // Check if it's a reference (N:N) collection registry.isReferenceCollection("Post", "tags"); // true registry.isReferenceCollection("Post", "comments"); // false // Check if it's an owned (1:N) collection registry.isOwnedCollection("Post", "comments"); // true registry.isOwnedCollection("Post", "tags"); // false // Get full collection config const tagConfig = registry.getCollectionConfig("Post", "tags"); // { type: "reference", entity: "Tag", junction: { ... } } ``` ### Mapping Domain Field Names to ORM Field Names When the domain property name and the ORM relation field name differ, use `relationName` to configure the mapping. ORM adapters such as `PrismaBatchExecutor` use `getRelationFieldName()` to resolve the correct field for `connect`/`disconnect` operations. ```typescript theme={null} // Prisma schema // model Post { // id String @id // post_tags Tag[] @relation("PostToTag") // } // Domain entity has a property named "tags", but Prisma expects "post_tags" registry.register({ entity: "Post", table: "post", collections: { tags: { type: "reference", entity: "Tag", relationName: "post_tags", // ← ORM field name used for connect/disconnect }, }, }); // Resolve the ORM field name at runtime registry.getRelationFieldName("Post", "tags"); // "post_tags" // Without relationName it falls back to the domain property name registry.getRelationFieldName("Post", "comments"); // "comments" ``` If you omit `relationName` and your Prisma relation field name differs from the domain property name, `PrismaBatchExecutor` will pass the wrong field to Prisma's `connect`/`disconnect` operations and the call will fail. Always set `relationName` when the names diverge. ## Query Methods ```typescript theme={null} // Check if entity is registered registry.has("User"); // true registry.has("Order"); // false // Get schema (throws if not found) const schema = registry.getSchema("User"); // Try get schema (returns null if not found) const schema = registry.tryGetSchema("Order"); // null // Get table name registry.getTable("User"); // "users" // Get all registered entity names registry.getRegisteredEntities(); // ["User", "Post", "Comment"] // Get all schemas registry.getAllSchemas(); // [{ entity: "User", ... }, ...] // Clear all registrations registry.clear(); ``` ## Using with Mappers The registry is commonly used in persistence mappers: ```typescript theme={null} import { Mapper, EntitySchemaRegistry } from "@woltz/rich-domain"; class UserToPersistenceMapper extends Mapper { constructor(private registry: EntitySchemaRegistry) { super(); } build(user: User): UserRecord { // Use registry to map the entity const data = this.registry.mapEntity("User", user); return { ...data, // Add any custom transformations status: user.status.toUpperCase(), }; } } ``` ## Using with BatchExecutor The registry integrates with `PrismaBatchExecutor` for automatic field mapping: ```typescript theme={null} import { PrismaBatchExecutor } from "@woltz/rich-domain-prisma"; const executor = new PrismaBatchExecutor(prismaContext, { registry: schemaRegistry, }); // BatchExecutor uses registry to: // - Get correct table names // - Map field names // - Handle FK relationships await executor.execute(changes); ``` ## Complete Example ```typescript theme={null} import { EntitySchemaRegistry } from "@woltz/rich-domain"; // Define the schema registry for your domain export const schemaRegistry = new EntitySchemaRegistry() // Root aggregate .register({ entity: "User", table: "users", fields: { firstName: "first_name", lastName: "last_name", createdAt: "created_at", updatedAt: "updated_at", }, collections: { posts: { type: "owned" }, followers: { type: "reference", entity: "User" }, }, }) // Child entity (1:N owned) .register({ entity: "Post", table: "posts", fields: { createdAt: "created_at", updatedAt: "updated_at", }, parentFk: { field: "author_id", parentEntity: "User", }, collections: { comments: { type: "owned" }, tags: { type: "reference", entity: "Tag", junction: { table: "post_tags", sourceKey: "post_id", targetKey: "tag_id", }, }, }, }) // Nested child (1:N owned) .register({ entity: "Comment", table: "comments", fields: { createdAt: "created_at", }, parentFk: { field: "post_id", parentEntity: "Post", }, }) // Independent entity (referenced via N:N) .register({ entity: "Tag", table: "tags", }); // Usage in mapper class UserToPersistenceMapper extends PrismaToPersistence { protected readonly registry = schemaRegistry; protected async onCreate(user: User): Promise { await this.context.user.create({ data: this.registry.mapEntity("User", user), }); } // onUpdate uses PrismaBatchExecutor by default — override only if needed } ``` ## API Reference ### EntitySchema Interface ```typescript theme={null} interface EntitySchema { entity: string; // Domain entity name table: string; // Database table name fields?: Record; // Field name mappings parentFk?: { field: string; // FK column name parentEntity: string; // Parent entity name }; collections?: Record; primaryKey?: string; // DB PK column (defaults to "id"); value always from entity.id } interface CollectionConfig { type: "owned" | "reference"; entity?: string; // Target entity (for reference) relationName?: string; // ORM relation field name when it differs from the domain property name junction?: { table: string; // Junction table name sourceKey: string; // FK to source entity targetKey: string; // FK to target entity }; } ``` ### Registry Methods | Method | Returns | Description | | -------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------ | | `register(schema)` | `this` | Register an entity schema | | `registerAll(schemas)` | `this` | Register multiple schemas | | `has(entity)` | `boolean` | Check if entity is registered | | `getSchema(entity)` | `EntitySchema` | Get schema (throws if not found) | | `tryGetSchema(entity)` | `EntitySchema \| null` | Get schema or null | | `getTable(entity)` | `string` | Get table name | | `getPrimaryKeyField(entity)` | `string` | Get database PK column (defaults to `"id"`) | | `buildWhereById(entity, id)` | `object` | Build ORM where clause for a single ID | | `buildWhereByIds(entity, ids)` | `object` | Build ORM where clause for multiple IDs | | `getFieldsMap(entity)` | `Record` | Get field mappings | | `mapFieldName(entity, field)` | `string` | Map single field name | | `mapFields(entity, data)` | `object` | Map partial fields | | `mapEntity(entity, domainEntity)` | `object` | Map complete entity | | `getParentEntity(entity)` | `string \| null` | Get parent entity name | | `getParentFkField(entity)` | `string \| null` | Get FK field name | | `getParentFk(entity, parentId)` | `object \| null` | Get FK object | | `getCollectionConfig(entity, field)` | `CollectionConfig \| null` | Get collection config | | `getRelationFieldName(entity, field)` | `string` | Resolve ORM relation field name (returns `relationName` if set, otherwise the domain field name) | | `isReferenceCollection(entity, field)` | `boolean` | Check if N:N relation | | `isOwnedCollection(entity, field)` | `boolean` | Check if 1:N relation | | `getRegisteredEntities()` | `string[]` | Get all entity names | | `getAllSchemas()` | `EntitySchema[]` | Get all schemas | | `clear()` | `void` | Remove all registrations | # Error Handling Source: https://woltz.mintlify.app/validation/error-handling Working with ValidationError, domain exceptions, and error handling patterns ## ValidationError `ValidationError` is the standard error type thrown when validation fails. It contains structured information about all validation issues. ```typescript theme={null} import { ValidationError } from "@woltz/rich-domain"; try { const user = new User({ name: "", email: "invalid" }); } catch (error) { if (ValidationError.isValidationError(error)) { console.log(error.message); // "Validation failed: Name is required, Invalid email format" console.log(error.issues); // [ // { path: ["name"], message: "Name is required" }, // { path: ["email"], message: "Invalid email format" } // ] } } ``` ## ValidationError API ### Properties ```typescript theme={null} interface ValidationError extends Error { name: "ValidationError"; message: string; // Summary of all errors issues: ValidationIssue[]; // Detailed error list } interface ValidationIssue { path: string[]; // Field path (e.g., ["address", "city"]) message: string; // Error message } ``` ### Methods #### getMessages() Get all error messages as a flat array: ```typescript theme={null} const messages = error.getMessages(); // ["Name is required", "Invalid email format", "Age must be positive"] ``` #### getFormattedErrors() Get errors formatted for UI/API consumption: ```typescript theme={null} const fieldErrors = error.getFormattedErrors(); // [ // { path: "name", message: "Name is required" }, // { path: "email", message: "Invalid email format" } // ] ``` Use this for form field bindings. The `path` is a dot-joined string (empty string for global errors). #### getErrorsForPath(path) Get errors for a specific field: ```typescript theme={null} const nameErrors = error.getErrorsForPath("name"); // [{ path: ["name"], message: "Name is required" }] const addressCityErrors = error.getErrorsForPath(["address", "city"]); // [{ path: ["address", "city"], message: "City is required" }] ``` #### hasErrorsForPath(path) Check if a field has errors: ```typescript theme={null} if (error.hasErrorsForPath("email")) { // Show email field error } ``` #### toJSON() Serialize for API responses: ```typescript theme={null} const json = error.toJSON(); // { // name: "ValidationError", // message: "Validation failed: ...", // issues: [...] // } ``` ### Static Methods #### isValidationError(error) Type-safe check that works across module boundaries: ```typescript theme={null} try { // ... } catch (error) { if (ValidationError.isValidationError(error)) { // error is typed as ValidationError console.log(error.issues); } } ``` Always use `ValidationError.isValidationError()` instead of `instanceof` for reliable detection across module boundaries. ## Throwing Validation Errors ### In Hooks Use the `throwValidationError` helper: ```typescript theme={null} import { throwValidationError } from "@woltz/rich-domain"; rules: (entity) => { if (entity.price < 0) { throwValidationError("price", "Price cannot be negative"); } }; ``` ### Manually Create and throw directly: ```typescript theme={null} import { ValidationError, createValidationIssue } from "@woltz/rich-domain"; // Single issue throw new ValidationError([ createValidationIssue("email", "Email already exists"), ]); // Multiple issues throw new ValidationError([ createValidationIssue("email", "Email already exists"), createValidationIssue("username", "Username already taken"), ]); // Nested path throw new ValidationError([ createValidationIssue(["address", "zipCode"], "Invalid ZIP code"), ]); ``` ## Non-Throwing Mode Configure entities to collect errors instead of throwing: ```typescript theme={null} class UserSafe extends Aggregate { protected static validation = { schema: userSchema, config: { onCreate: true, onUpdate: true, throwOnError: false, // Collect errors }, }; } const user = new UserSafe({ name: "", email: "invalid" }); // Check for errors if (user.hasValidationErrors) { const errors = user.validationErrors!; console.log(errors.getMessages()); // ["Name is required", "Invalid email format"] console.log(errors.getFormattedErrors()); // [{ path: "name", message: "..." }, { path: "email", message: "..." }] } ``` ### Collecting Issues in rules (without throwing) When `throwOnError` is `false`, use `addValidationIssue` inside `rules` to accumulate business rule violations: ```typescript theme={null} rules: (entity) => { if (entity.age > 90) { entity.addValidationIssue("age", "Age cannot exceed 90 years"); } }; ``` Issues from schema validation and `addValidationIssue` are merged into `validationErrors`. Use `throwValidationError` when you want fail-fast behavior (always throws, regardless of `throwOnError`). ### persistInvalidMutations When `throwOnError` is `false`, this flag defines whether invalid **updates** are kept on the entity: ```typescript theme={null} // Default: dirty / form mode — mutations apply, errors collected config: { throwOnError: false, } // Freeze — block changes while invalid; revert failed updates config: { throwOnError: false, persistInvalidMutations: false, } ``` **`persistInvalidMutations: true`** (default) * Property changes apply even when schema or `rules` fail. * `validationErrors` reflects the latest validation of the current props (all fields). * Matches form UX: what the user typed is what `toJSON()` returns. ```typescript theme={null} const user = new UserSafe({ name: "Tarcisio", email: "root@root.com", age: 91, ... }); // hasValidationErrors: age rule from create user.changeName("New Name"); user.changeRole(new Role("User")); user.removeAddress(); // toJSON() reflects applied mutations; age issue remains until fixed expect(user.toJSON().name).toBe("New Name"); expect(user.hasValidationErrors).toBe(true); ``` **`persistInvalidMutations: false`** * While `validationErrors` exists, further mutations are blocked (including `delete`). * A single update that fails validation is reverted; errors are still recorded. Before `save()` or exporting changes, validate `!entity.hasValidationErrors` when using dirty mode. ### When to Use Non-Throwing Mode Collect all errors to display to user at once Log errors but continue processing Allow incomplete entities during construction Combine errors from multiple sources ## API Error Responses ### Express.js Example ```typescript theme={null} import express from "express"; import { ValidationError } from "@woltz/rich-domain"; const app = express(); // Error handling middleware app.use( ( err: Error, req: express.Request, res: express.Response, next: express.NextFunction ) => { if (ValidationError.isValidationError(err)) { return res.status(400).json({ error: "Validation Error", message: err.message, details: err.issues.map((issue) => ({ field: issue.path.join("."), message: issue.message, })), }); } // Other errors console.error(err); res.status(500).json({ error: "Internal Server Error" }); } ); // Route app.post("/users", async (req, res, next) => { try { const user = new User(req.body); await userRepository.save(user); res.status(201).json(user.toJSON()); } catch (error) { next(error); // Pass to error handler } }); ``` ### Response Format ```json theme={null} { "error": "Validation Error", "message": "Validation failed: Name is required, Invalid email format", "details": [ { "field": "name", "message": "Name is required" }, { "field": "email", "message": "Invalid email format" } ] } ``` *** ## Domain Exceptions Beyond `ValidationError`, rich-domain provides a comprehensive set of domain exceptions for different error scenarios. All exceptions extend a common `DomainException` base class. ```typescript theme={null} import { DomainError, EntityNotFoundError, EntityAlreadyExistsError, RepositoryError, PersistenceError, ConcurrencyError, // ... more } from "@woltz/rich-domain"; ``` ### Exception Hierarchy ``` DomainException (base) ├── DomainError ├── EntityNotFoundError ├── EntityAlreadyExistsError ├── InvalidValueObjectError ├── InvalidCriteriaError ├── RepositoryError │ ├── PersistenceError │ ├── ConcurrencyError │ └── ConstraintViolationError ├── DomainEventError │ └── EventHandlerError ├── TransactionError ├── MapperError ├── ConfigurationError ├── NotImplementedError └── UnknownError ``` ### Common Properties All domain exceptions share these properties: ```typescript theme={null} interface DomainException extends Error { name: string; // Exception class name message: string; // Error message code: string; // Error code (e.g., "ENTITY_NOT_FOUND") timestamp: Date; // When the error occurred toJSON(): object; // Serialize for API responses } // Type checking (works across module boundaries) DomainException.isDomainException(error); // boolean ``` *** ## Entity & Aggregate Exceptions ### DomainError General-purpose exception for business rule violations: ```typescript theme={null} import { DomainError } from "@woltz/rich-domain"; class Order extends Aggregate { confirm() { if (this.items.length === 0) { throw new DomainError("Cannot confirm an empty order"); } if (this.status !== "draft") { throw new DomainError( "Only draft orders can be confirmed", "ORDER_NOT_DRAFT" // Optional custom code ); } this.props.status = "confirmed"; } } ``` ### EntityNotFoundError When an entity or aggregate cannot be found: ```typescript theme={null} import { EntityNotFoundError } from "@woltz/rich-domain"; class UserRepository { async findByIdOrFail(id: string): Promise { const user = await this.findById(id); if (!user) { throw new EntityNotFoundError("User", id); // Message: "User with id 'abc-123' not found" // Code: "ENTITY_NOT_FOUND" } return user; } } // With custom message throw new EntityNotFoundError( "User", id, "The requested user account does not exist" ); ``` ### EntityAlreadyExistsError When trying to create an entity that already exists: ```typescript theme={null} import { EntityAlreadyExistsError } from "@woltz/rich-domain"; class UserService { async createUser(email: string, name: string): Promise { const existing = await this.userRepo.findByEmail(email); if (existing) { throw new EntityAlreadyExistsError("User", existing.id.value); // Message: "User with id 'abc-123' already exists" } return new User({ email, name }); } } ``` *** ## Repository & Persistence Exceptions ### RepositoryError Base exception for repository operations: ```typescript theme={null} import { RepositoryError } from "@woltz/rich-domain"; throw new RepositoryError("Failed to connect to database"); ``` ### PersistenceError When a database operation fails: ```typescript theme={null} import { PersistenceError } from "@woltz/rich-domain"; class UserRepository { async save(user: User): Promise { try { await this.db.user.upsert({ ... }); } catch (error) { throw new PersistenceError( "save", // Operation name "Database connection lost", // Message error as Error // Original error (cause) ); } } } // Access details catch (error) { if (error instanceof PersistenceError) { console.log(error.operation); // "save" console.log(error.cause); // Original database error } } ``` ### ConcurrencyError For optimistic locking conflicts: ```typescript theme={null} import { ConcurrencyError } from "@woltz/rich-domain"; class OrderRepository { async save(order: Order): Promise { const result = await this.db.order.updateMany({ where: { id: order.id.value, version: order.version, // Optimistic lock }, data: { ...orderData, version: { increment: 1 }, }, }); if (result.count === 0) { throw new ConcurrencyError("Order", order.id.value); // Message: "Concurrency conflict detected for Order with id 'order-123'" } } } ``` ### ConstraintViolationError When a database constraint is violated: ```typescript theme={null} import { ConstraintViolationError } from "@woltz/rich-domain"; class UserRepository { async save(user: User): Promise { try { await this.db.user.create({ data: userData }); } catch (error) { if (isPrismaUniqueConstraintError(error)) { throw new ConstraintViolationError( "users_email_unique", "A user with this email already exists" ); } throw error; } } } ``` *** ## Other Exceptions ### InvalidValueObjectError When a Value Object receives invalid data, it throws automatically with schema validation: ```typescript theme={null} import { ValueObject, InvalidValueObjectError } from "@woltz/rich-domain"; import { z } from "zod"; const emailSchema = z.string().email("Invalid email format"); class Email extends ValueObject { protected static validation = { schema: emailSchema, }; } try { const email = new Email("not-an-email"); } catch (error) { if (error instanceof InvalidValueObjectError) { console.log(error.message); // "Invalid email format" console.log(error.value); // "not-an-email" } } ``` ### InvalidCriteriaError When a Criteria query is invalid: ```typescript theme={null} import { InvalidCriteriaError } from "@woltz/rich-domain"; // Thrown automatically by Criteria when: // - Invalid operator for field type // - Invalid quantifier value // Example: manually throwing if (!allowedFields.includes(field)) { throw new InvalidCriteriaError( `Field '${field}' is not allowed for filtering`, field ); } ``` ### TransactionError When a transaction operation fails: ```typescript theme={null} import { TransactionError } from "@woltz/rich-domain"; class UnitOfWork { async commit(): Promise { try { await this.db.$transaction(this.operations); } catch (error) { throw new TransactionError( "commit", "Failed to commit transaction", error as Error ); } } } ``` ### MapperError When mapping between domain and persistence fails: ```typescript theme={null} import { MapperError } from "@woltz/rich-domain"; class UserToDomainMapper { build(record: UserRecord): User { try { return new User({ id: Id.from(record.id), name: record.name, email: record.email, }); } catch (error) { throw new MapperError( "toDomain", "User", "Failed to map user record to domain", error as Error ); } } } ``` ### DomainEventError & EventHandlerError For event-related failures: ```typescript theme={null} import { DomainEventError, EventHandlerError } from "@woltz/rich-domain"; // General event error throw new DomainEventError("Failed to publish event", "UserCreatedEvent"); // Handler-specific error throw new EventHandlerError( "SendWelcomeEmailHandler", "UserCreatedEvent", originalError ); ``` ### Utility Exceptions ```typescript theme={null} import { NotImplementedError, ConfigurationError, UnknownError, } from "@woltz/rich-domain"; // Feature not yet implemented throw new NotImplementedError("Bulk import"); // Missing or invalid configuration throw new ConfigurationError("Database URL is required", "DATABASE_URL"); // Wrap unknown errors throw new UnknownError("An unexpected error occurred", originalError); ``` *** ## Handling Domain Exceptions ### Centralized Error Handler ```typescript theme={null} import { ValidationError, EntityNotFoundError, EntityAlreadyExistsError, ConcurrencyError, DomainError, } from "@woltz/rich-domain"; function handleError(error: Error, res: Response) { // Validation errors → 400 if (ValidationError.isValidationError(error)) { return res.status(400).json({ error: "Validation Error", message: error.message, details: error.issues, }); } // Not found → 404 if (error instanceof EntityNotFoundError) { return res.status(404).json({ error: "Not Found", message: error.message, entityType: error.entityType, entityId: error.entityId, }); } // Already exists → 409 if (error instanceof EntityAlreadyExistsError) { return res.status(409).json({ error: "Conflict", message: error.message, }); } // Concurrency conflict → 409 if (error instanceof ConcurrencyError) { return res.status(409).json({ error: "Conflict", message: "Resource was modified by another request", code: error.code, }); } // Domain errors → 422 if (error instanceof DomainError) { return res.status(422).json({ error: "Unprocessable Entity", message: error.message, code: error.code, }); } // Unknown errors → 500 console.error("Unhandled error:", error); return res.status(500).json({ error: "Internal Server Error", message: "An unexpected error occurred", }); } ``` ### Exception Reference Table | Exception | HTTP Status | When to Use | | -------------------------- | ----------- | -------------------------- | | `ValidationError` | 400 | Invalid input data | | `InvalidCriteriaError` | 400 | Invalid query parameters | | `EntityNotFoundError` | 404 | Resource not found | | `EntityAlreadyExistsError` | 409 | Duplicate resource | | `ConcurrencyError` | 409 | Optimistic lock conflict | | `ConstraintViolationError` | 409 | Database constraint failed | | `DomainError` | 422 | Business rule violation | | `InvalidValueObjectError` | 422 | Invalid value object | | `PersistenceError` | 500 | Database error | | `TransactionError` | 500 | Transaction failed | | `MapperError` | 500 | Mapping failed | | `ConfigurationError` | 500 | Configuration missing | # Lifecycle Hooks Source: https://woltz.mintlify.app/validation/lifecycle-hooks Custom validation rules and lifecycle callbacks ## Overview Hooks provide a way to add custom logic at specific points in an entity's lifecycle. They complement schema validation with business rules that can't be expressed in a schema. ```typescript theme={null} class User extends Aggregate { protected static hooks: EntityHooks = { onBeforeCreate: (props) => { console.log(`User (PROPS: Plain Object): ${props.name}`); }, onCreate: (entity) => { console.log(`User (Entity) created: ${entity.name}`); }, onBeforeUpdate: (entity, snapshot) => { // Block certain changes if (snapshot.email !== entity.email) { return false; // Reject email change } return true; }, rules: (entity) => { // Custom business rules if (entity.name.toLowerCase() === "admin") { throwValidationError("name", 'Name cannot be "admin"'); } }, }; } ``` ## Available Hooks ### onBeforeCreate #### Usage Called before the entity is created and validated: ```typescript theme={null} protected static hooks: EntityHooks = { onBeforeCreate: (props) => { // Props is a plain object console.log(`User ${props.name}`); }, }; ``` **Timing:** Runs before constructor completes, before any execution. #### Use Cases * Change data before it is validated * Generate required values that are optional at input #### Generating Required Values Use `onBeforeCreate` with optional input properties to generate values internally: ```typescript theme={null} import { z } from "zod"; import { Aggregate, Id, EntityValidation, EntityHooks } from "@woltz/rich-domain"; const userSchema = z.object({ id: z.custom((val) => val instanceof Id), email: z.string().email(), password: z.string().min(8), // Required in entity passwordResetToken: z.string().uuid(), // Required in entity createdAt: z.date(), }); type UserProps = z.infer; // Mark fields as optional at input class User extends Aggregate { protected static validation: EntityValidation = { schema: userSchema, }; protected static hooks: EntityHooks = { onBeforeCreate: (props) => { // Generate encrypted password if not provided if (!props.password) { const tempPassword = generateRandomPassword(); props.password = encrypt(tempPassword); } // Generate password reset token if (!props.passwordResetToken) { props.passwordResetToken = crypto.randomUUID(); } // Set timestamp if (!props.createdAt) { props.createdAt = new Date(); } }, }; get email() { return this.props.email; } } // ✅ Create user without password - it will be generated const user = new User({ email: "user@example.com", }); // ✅ Or provide custom password const userWithPassword = new User({ email: "user@example.com", password: encrypt("my-secure-password"), }); ``` Values generated in `onBeforeCreate` are still validated by the schema. If generation fails to meet schema requirements, a `ValidationError` will be thrown. ### onCreate Called after the entity is successfully created and validated: ```typescript theme={null} protected static hooks: EntityHooks = { onCreate: (entity) => { // Entity is fully constructed and valid console.log(`User ${entity.id.value} created`); // Initialize derived values entity.initializeDefaults(); // Emit domain event entity.addDomainEvent(new UserCreatedEvent(entity.id)); }, }; ``` **Timing:** Runs after constructor completes, after schema validation. **Use cases:** * Logging * Initializing computed values * Adding domain events * Setting up internal state ### onBeforeUpdate Called before a property change is applied. Return `false` to reject the change: ```typescript theme={null} protected static hooks: EntityHooks = { onBeforeUpdate: (entity, snapshot) => { // snapshot = original values before change // entity = current values (change already applied for validation) // Prevent email changes if (snapshot.email !== entity.email) { console.log("Email change blocked"); return false; // Reject change } // Prevent status downgrade if (snapshot.status === "verified" && entity.status === "pending") { return false; } return true; // Allow change }, }; ``` **Timing:** Runs before property change is finalized. **Parameters:** * `entity`: Current state (with proposed change) * `snapshot`: State before the change **Return value:** * `true` or `undefined`: Allow the change * `false`: Reject the change (value reverts) ### rules Custom validation rules that run after schema validation: ```typescript theme={null} protected static hooks: EntityHooks = { rules: (entity) => { // Reserved names const reserved = ["admin", "system", "root"]; if (reserved.includes(entity.name.toLowerCase())) { throwValidationError("name", "This name is reserved"); } // Cross-field validation if (entity.role === "admin" && !entity.email.endsWith("@company.com")) { throwValidationError("email", "Admin must use company email"); } // Complex business rules if (entity.age < 18 && entity.accountType === "premium") { throwValidationError("accountType", "Premium requires age 18+"); } }, }; ``` **Timing:** Runs after schema validation, both on create and update. **Use cases:** * Cross-field validation * Business rule validation * Reserved value checks * Complex conditional rules ## Collecting Validation Issues (Non-Throwing) When `throwOnError` is `false`, accumulate issues in `rules` without throwing: ```typescript theme={null} rules: (entity) => { if (entity.age > 90) { entity.addValidationIssue("age", "Age cannot exceed 90 years"); } }; ``` After construction or update, check `entity.hasValidationErrors` and `entity.validationErrors?.getFormattedErrors()` for UI display. ## Throwing Validation Errors Use `throwValidationError` helper in rules for fail-fast validation: ```typescript theme={null} import { throwValidationError } from "@woltz/rich-domain"; rules: (entity) => { if (entity.price < entity.cost) { throwValidationError("price", "Price cannot be less than cost"); } if (entity.endDate < entity.startDate) { throwValidationError("endDate", "End date must be after start date"); } }; ``` The helper throws a `ValidationError` with proper structure: ```typescript theme={null} // Equivalent to: throw new ValidationError([ { path: ["price"], message: "Price cannot be less than cost" }, ]); ``` ## Combining Schema and Hooks Schema validation runs first, then hooks: ```typescript theme={null} class Product extends Aggregate { // 1. Schema validates structure and types protected static validation: EntityValidation = { schema: z.object({ id: z.custom((val) => val instanceof Id), name: z.string().min(1), price: z.number().positive(), cost: z.number().positive(), sku: z.string().regex(/^[A-Z]{3}-\d{4}$/), }), config: { onCreate: true, onUpdate: true, throwOnError: true }, }; // 2. Hooks validate business rules protected static hooks: EntityHooks = { rules: (entity) => { // Cross-field validation not possible in schema if (entity.price < entity.cost * 1.1) { throwValidationError("price", "Price must be at least 10% above cost"); } }, onBeforeUpdate: (entity, snapshot) => { // Prevent SKU changes after creation if (!entity.isNew() && snapshot.sku !== entity.sku) { return false; } return true; }, onCreate: (entity) => { console.log(`Product ${entity.sku} created at $${entity.price}`); }, }; } ``` ## Execution Order ``` Constructor Called ↓ onBeforeCreate Hook ↓ Schema Validation (if onCreate: true) ↓ rules() Hook ↓ onCreate() Hook ↓ Entity Ready Property Change ↓ onBeforeUpdate() Hook → false? → Reject Change ↓ Schema Validation (if onUpdate: true) ↓ rules() Hook ↓ Change Applied ``` ## Hooks for Value Objects Value Objects support hooks for primitive values: ```typescript theme={null} import { ValueObject, VOHooks, throwValidationError } from "@woltz/rich-domain"; class Price extends ValueObject { protected static hooks: VOHooks = { onBeforeCreate: (value) => { console.log(`Creating price: ${value}`); }, rules: (price) => { if (price.value < 0) { throwValidationError("value", "Price cannot be negative"); } if (price.value > 1000000) { throwValidationError("value", "Price cannot exceed 1,000,000"); } }, onCreate: (price) => { console.log(`Price created: $${price.value}`); }, }; format(): string { return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", }).format(this.value); } } const price = new Price(99.99); // Logs: Creating price: 99.99 // Logs: Price created: $99.99 ``` Value Objects don't have `onBeforeUpdate` because they're immutable - you create new instances instead of modifying. ## Real-World Examples ### User Registration Rules ```typescript theme={null} class User extends Aggregate { protected static hooks: EntityHooks = { rules: (user) => { // Username rules if (user.username.length < 3) { throwValidationError( "username", "Username must be at least 3 characters" ); } if (!/^[a-zA-Z0-9_]+$/.test(user.username)) { throwValidationError( "username", "Username can only contain letters, numbers, and underscores" ); } // Age verification if (user.birthDate) { const age = calculateAge(user.birthDate); if (age < 13) { throwValidationError("birthDate", "Must be at least 13 years old"); } } // Email domain restrictions const blockedDomains = ["tempmail.com", "throwaway.com"]; const domain = user.email.split("@")[1]; if (blockedDomains.includes(domain)) { throwValidationError("email", "Temporary email addresses not allowed"); } }, onBeforeUpdate: (user, snapshot) => { // Prevent username changes after 30 days const daysSinceCreation = daysBetween(user.createdAt, new Date()); if (daysSinceCreation > 30 && snapshot.username !== user.username) { return false; } // Prevent role downgrades const roleHierarchy = ["user", "moderator", "admin"]; const oldRoleIndex = roleHierarchy.indexOf(snapshot.role); const newRoleIndex = roleHierarchy.indexOf(user.role); if (newRoleIndex < oldRoleIndex) { return false; // Can't downgrade } return true; }, onCreate: (user) => { user.addDomainEvent(new UserRegisteredEvent(user.id, user.email)); }, }; } ``` ### Order Business Rules ```typescript theme={null} class Order extends Aggregate { protected static hooks: EntityHooks = { rules: (order) => { // Minimum order value if (order.total < 10) { throwValidationError("total", "Minimum order value is $10"); } // Maximum items per order if (order.items.length > 50) { throwValidationError("items", "Maximum 50 items per order"); } // Shipping address required for physical products const hasPhysical = order.items.some((i) => !i.isDigital); if (hasPhysical && !order.shippingAddress) { throwValidationError( "shippingAddress", "Shipping address required for physical products" ); } // Validate item quantities for (const item of order.items) { if (item.quantity > item.maxPerOrder) { throwValidationError( "items", `${item.name}: maximum ${item.maxPerOrder} per order` ); } } }, onBeforeUpdate: (order, snapshot) => { // Can't modify confirmed orders if (snapshot.status !== "draft") { // Only allow status transitions if (JSON.stringify(snapshot.items) !== JSON.stringify(order.items)) { return false; } if (snapshot.total !== order.total) { return false; } } // Validate status transitions const validTransitions: Record = { draft: ["confirmed", "cancelled"], confirmed: ["processing", "cancelled"], processing: ["shipped", "cancelled"], shipped: ["delivered"], delivered: [], cancelled: [], }; if (snapshot.status !== order.status) { if (!validTransitions[snapshot.status].includes(order.status)) { return false; } } return true; }, }; } ``` ### Inventory Rules ```typescript theme={null} class InventoryItem extends Aggregate { protected static hooks: EntityHooks = { rules: (item) => { // Stock cannot be negative if (item.quantity < 0) { throwValidationError("quantity", "Stock cannot be negative"); } // Reorder point validation if (item.reorderPoint > item.maxStock) { throwValidationError( "reorderPoint", "Reorder point cannot exceed max stock" ); } // Location format if (item.location && !/^[A-Z]\d{2}-\d{3}$/.test(item.location)) { throwValidationError("location", "Location must be in format A00-000"); } }, onBeforeUpdate: (item, snapshot) => { // Log significant quantity changes const change = item.quantity - snapshot.quantity; if (Math.abs(change) > 100) { console.warn( `Large inventory change: ${item.sku} changed by ${change}` ); } // Prevent changes to archived items if (snapshot.status === "archived") { return false; } return true; }, }; } ``` # Overview Source: https://woltz.mintlify.app/validation/overview Schema-based validation using Standard Schema specification ## Overview rich-domain provides built-in validation support using the [Standard Schema](https://standardschema.dev/) specification. This means you can use your favorite validation library - Zod, Valibot, ArkType, or any other Standard Schema-compatible library. ```typescript theme={null} import { z } from "zod"; const userSchema = z.object({ id: z.custom((val) => val instanceof Id), name: z.string().min(2), email: z.string().email(), age: z.number().min(0).max(150), }); class User extends Aggregate { protected static validation = { schema: userSchema, config: { onCreate: true, onUpdate: true, throwOnError: true, }, }; } ``` ## Why Standard Schema? Use Zod, Valibot, ArkType, or any Standard Schema-compatible library Get full TypeScript inference from your schemas Control when validation runs (create, update, or both) Choose between throwing errors or collecting them ## Supported Libraries Any library implementing the Standard Schema spec works: ```typescript Zod theme={null} import { z } from "zod"; const schema = z.object({ name: z.string().min(2), email: z.string().email(), }); ``` ```typescript Valibot theme={null} import * as v from "valibot"; const schema = v.object({ name: v.pipe(v.string(), v.minLength(2)), email: v.pipe(v.string(), v.email()), }); ``` ```typescript ArkType theme={null} import { type } from "arktype"; const schema = type({ name: "string>=2", email: "email", }); ``` ## Basic Usage ### 1. Define Your Schema ```typescript theme={null} import { z } from "zod"; import { Id } from "@woltz/rich-domain"; const productSchema = z.object({ id: z.custom((val) => val instanceof Id), name: z.string().min(1, "Name is required"), price: z.number().positive("Price must be positive"), stock: z.number().int().min(0, "Stock cannot be negative"), status: z.enum(["draft", "published", "archived"]), }); type ProductProps = z.infer; ``` ### 2. Apply to Entity/Aggregate ```typescript theme={null} import { Aggregate, EntityValidation } from "@woltz/rich-domain"; class Product extends Aggregate { protected static validation: EntityValidation = { schema: productSchema, config: { onCreate: true, // Validate on construction onUpdate: true, // Validate on property changes throwOnError: true, // Throw ValidationError on failure }, }; get name() { return this.props.name; } set name(value: string) { this.props.name = value; } get price() { return this.props.price; } set price(value: number) { this.props.price = value; } } ``` ### 3. Validation in Action ```typescript theme={null} // Valid - no error const product = new Product({ name: "Widget", price: 29.99, stock: 100, status: "draft", }); // Invalid - throws ValidationError const invalid = new Product({ name: "", // ❌ Name is required price: -10, // ❌ Price must be positive stock: 5.5, // ❌ Stock must be integer status: "draft", }); ``` ## Validation Configuration ```typescript theme={null} interface ValidationConfig { onCreate?: boolean; // Validate in constructor (default: true) onUpdate?: boolean; // Validate on property changes (default: true) throwOnError?: boolean; // Throw or collect errors (default: true) persistInvalidMutations?: boolean; // Keep invalid updates on entity (default: true when throwOnError is false) } ``` ### onCreate When `true`, validates the entire entity when constructed: ```typescript theme={null} class Product extends Aggregate { protected static validation = { schema: productSchema, config: { onCreate: true }, }; } // Validation runs here const product = new Product({ name: "", price: -1 }); // ValidationError: Name is required, Price must be positive ``` ### onUpdate When `true`, validates after every property change: ```typescript theme={null} class Product extends Aggregate { protected static validation = { schema: productSchema, config: { onUpdate: true }, }; set price(value: number) { this.props.price = value; // Validation runs automatically after this } } const product = new Product({ name: "Widget", price: 29.99, ... }); product.price = -10; // ValidationError: Price must be positive ``` ### throwOnError Controls error handling behavior: ```typescript theme={null} // throwOnError: true (default) // Throws ValidationError immediately // throwOnError: false // Stores errors internally, doesn't throw ``` ### persistInvalidMutations Only applies when `throwOnError` is `false`. Controls how **updates** behave while the entity is invalid: | Value | Update behavior | | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `true` (default) | **Dirty / form mode** — mutations apply even when schema or `rules` fail; `validationErrors` is refreshed to match the current state (same idea as invalid data on create). | | `false` | **Freeze mode** — no property changes while `validationErrors` already exist; updates that would fail validation are reverted. | ```typescript theme={null} // Form: user can edit every field; UI reads validationErrors (default) config: { throwOnError: false } // Strict: entity cannot change until all errors are cleared config: { throwOnError: false, persistInvalidMutations: false } ``` With `persistInvalidMutations: true`, `toJSON()`, `getChanges()`, and repository `save()` may see invalid props. Check `hasValidationErrors` before persisting. ## Validation for Value Objects Value Objects also support validation: ```typescript theme={null} import { ValueObject } from "@woltz/rich-domain"; const emailSchema = z.string().email("Invalid email format"); class Email extends ValueObject { protected static validation = { schema: emailSchema, config: { onCreate: true, throwOnError: true }, }; getDomain(): string { return this.value.split("@")[1]; } } // Valid const email = new Email("john@example.com"); // Invalid - throws const invalid = new Email("not-an-email"); // ValidationError: Invalid email format ``` ## Non-Throwing Mode Collect errors without throwing: ```typescript theme={null} class ProductSafe extends Aggregate { protected static validation = { schema: productSchema, config: { onCreate: true, throwOnError: false, // Don't throw }, }; } const product = new ProductSafe({ name: "", price: -10, stock: 100, status: "draft", }); // Entity is created, but has errors if (product.hasValidationErrors) { console.log(product.validationErrors?.getMessages()); // ["Name is required", "Price must be positive"] } ``` ## ValidationError The `ValidationError` class provides rich error information: ```typescript theme={null} try { const product = new Product({ name: "", price: -10, ... }); } catch (error) { if (ValidationError.isValidationError(error)) { // Get all error messages error.getMessages(); // ["Name is required", "Price must be positive"] // Get errors for specific field error.getErrorsForPath("price"); // [{ path: ["price"], message: "Price must be positive" }] // Check if specific field has errors error.hasErrorsForPath("name"); // true // Access raw issues error.issues; // [ // { path: ["name"], message: "Name is required" }, // { path: ["price"], message: "Price must be positive" } // ] // Serialize for API response error.toJSON(); // { name: "ValidationError", message: "...", issues: [...] } } } ``` ## Complete Example ```typescript theme={null} import { z } from "zod"; import { Id, Aggregate, EntityValidation, EntityHooks, ValidationError, } from "@woltz/rich-domain"; // Schema const orderSchema = z.object({ id: z.custom((val) => val instanceof Id), customerId: z.string().uuid(), items: z .array( z.object({ productId: z.string().uuid(), quantity: z.number().int().positive(), unitPrice: z.number().positive(), }) ) .min(1, "Order must have at least one item"), status: z.enum(["draft", "confirmed", "shipped", "delivered"]), total: z.number().min(0), }); type OrderProps = z.infer; class Order extends Aggregate { protected static validation: EntityValidation = { schema: orderSchema, config: { onCreate: true, onUpdate: true, throwOnError: true, }, }; get items() { return this.props.items; } get total() { return this.props.total; } get status() { return this.props.status; } addItem(productId: string, quantity: number, unitPrice: number) { this.props.items.push({ productId, quantity, unitPrice }); this.recalculateTotal(); } private recalculateTotal() { this.props.total = this.props.items.reduce( (sum, item) => sum + item.quantity * item.unitPrice, 0 ); } confirm() { if (this.props.items.length === 0) { throw new Error("Cannot confirm empty order"); } this.props.status = "confirmed"; } } // Usage try { const order = new Order({ customerId: "123e4567-e89b-12d3-a456-426614174000", items: [], // ❌ Empty - will fail validation status: "draft", total: 0, }); } catch (error) { if (ValidationError.isValidationError(error)) { console.log(error.getMessages()); // ["Order must have at least one item"] } } ``` ## Next Steps Lifecycle hooks for custom validation rules Working with ValidationError and error responses