Documentation Index Fetch the complete documentation index at: https://woltz.mintlify.app/llms.txt
Use this file to discover all available pages before exploring further.
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.
npm install @woltz/rich-domain @woltz/rich-domain-prisma
Unit of Work Request-isolated transactions with AsyncLocalStorage
Repository Base Class PrismaRepository with built-in Criteria support
Change Tracking PrismaToPersistence with automatic change detection
Batch Operations PrismaBatchExecutor for efficient bulk writes
Quick Start
1. Setup
import { PrismaClient } from "@prisma/client" ;
import { PrismaUnitOfWork } from "@woltz/rich-domain-prisma" ;
const prisma = new PrismaClient ();
const uow = new PrismaUnitOfWork ( prisma );
2. Create Repository
import { PrismaRepository } from "@woltz/rich-domain-prisma" ;
class UserRepository extends PrismaRepository < User , UserRecord > {
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
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 < User >()
. 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.
import { PrismaUnitOfWork } from "@woltz/rich-domain-prisma" ;
const uow = new PrismaUnitOfWork ( prisma );
Transaction Execution
// 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:
// 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.
import { Transactional } from "@woltz/rich-domain-prisma" ;
class CreateUserUseCase {
constructor (
private readonly userRepository : UserRepository ,
private readonly uow : PrismaUnitOfWork
) {}
@ Transactional ()
async execute ( input : CreateUserInput ) : Promise < User > {
// 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:
class CreateUserUseCase {
constructor ( private readonly userRepository : UserRepository ) {}
@ Transactional ( myUnitOfWork )
async execute ( input : CreateUserInput ) : Promise < User > {
// 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:
Decorator parameter - @Transactional(myUow)
Instance property - this.uow
Private property - this._uow
Any property - Any property that is a PrismaUnitOfWork instance
// 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.
import { PrismaRepository } from "@woltz/rich-domain-prisma" ;
abstract class PrismaRepository < TDomain , TPersistence , TContext = PrismaClientLike > {
// 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 < string , any > = {};
// Built-in methods
async find ( criteria : Criteria < TDomain >) : Promise < PaginatedResult < TDomain >>;
async findById ( id : string ) : Promise < TDomain | null >;
async findManyByIds ( ids : string []) : Promise < TDomain []>;
async count ( criteria ?: Criteria < TDomain >) : Promise < number >;
async exists ( id : string ) : Promise < boolean >;
async save ( entity : TDomain ) : Promise < void >;
async delete ( entity : TDomain ) : Promise < void >;
async deleteById ( id : string ) : Promise < void >;
async transaction < T >( work : () => Promise < T >) : Promise < T >;
}
Complete Implementation
import { PrismaRepository , PrismaUnitOfWork } from "@woltz/rich-domain-prisma" ;
class UserRepository extends PrismaRepository < User , UserRecord > {
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
);
}
// 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 < User | null > {
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:
class UserRepository extends PrismaRepository < User , UserRecord > {
// Use this.context for transaction-aware queries
async customQuery () : Promise < User []> {
const data = await this . context . user . findMany ({
where: { status: "active" },
include: this . includes ,
});
return data . map (( d ) => this . toDomainMapper . build ( d ));
}
}
EntitySchemaRegistry
Maps domain entities to database tables, handles field mapping, and configures relationships.
Basic Registration
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":
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 owned1:N createMany / deleteManyreferenceN:N connect / disconnect
The PrismaBatchExecutor automatically uses the correct Prisma operations based on collection type:
// 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.
import { PrismaToPersistence } from "@woltz/rich-domain-prisma" ;
import { EntitySchemaRegistry , AggregateChanges } from "@woltz/rich-domain" ;
abstract class PrismaToPersistence < TDomain , PrismaClient = PrismaClientLike > extends Mapper < TDomain , void > {
// Required: registry for field mapping
protected abstract readonly registry : EntitySchemaRegistry ;
// Required: handle entity creation
protected abstract onCreate ( entity : TDomain ) : Promise < void >;
// Required: handle entity update with changes
protected abstract onUpdate (
changes : AggregateChanges ,
entity : TDomain
) : Promise < void >;
// Returns transaction client when inside a transaction, otherwise the prisma client
protected get context () : PrismaClient ;
}
Complete Example
class UserToPersistenceMapper extends PrismaToPersistence < User > {
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 < void > {
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 })),
},
},
});
}
protected async onUpdate (
changes : AggregateChanges ,
user : User
) : Promise < void > {
const executor = new PrismaBatchExecutor ( this . context , {
registry: this . registry ,
});
await executor . execute ( changes );
}
}
PrismaBatchExecutor
Executes batch operations from AggregateChanges with proper ordering and relationship handling.
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:
Deletes - Leaf → Root (depth DESC)
owned: Uses deleteMany
reference: Uses disconnect
Creates - Root → Leaf (depth ASC)
owned: Uses createMany
reference: Uses connect
Updates - Any order
Convenience Function
import { executeBatch } from "@woltz/rich-domain-prisma" ;
// Shorthand for simple cases
await executeBatch ( context , changes , {
registry: schemaRegistry ,
});
Complete Example
Domain Model
import { z } from "zod" ;
import { Aggregate , Entity , Id , EntityValidation } from "@woltz/rich-domain" ;
// Post Entity
const postSchema = z . object ({
id: z . custom < Id >(( v ) => v instanceof Id ),
title: z . string (). min ( 1 ),
content: z . string (),
published: z . boolean (). default ( false ),
});
class Post extends Entity < z . infer < typeof postSchema >> {
protected static validation : EntityValidation < z . infer < typeof postSchema >> = {
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 < Id >(( v ) => v instanceof Id ),
name: z . string (). min ( 2 ),
email: z . string (). email (),
posts: z . array ( z . custom < Post >(( v ) => v instanceof Post )),
tags: z . array ( z . custom < Tag >(( v ) => v instanceof Tag )),
});
class User extends Aggregate < z . infer < typeof userSchema >> {
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
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
import { Mapper } from "@woltz/rich-domain" ;
import { PrismaToPersistence , PrismaBatchExecutor } from "@woltz/rich-domain-prisma" ;
// Domain Mapper
class UserToDomainMapper extends Mapper < UserRecord , User > {
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 < User > {
protected readonly registry = schemaRegistry ;
protected async onCreate ( user : User ) : Promise < void > {
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 })),
},
},
});
}
protected async onUpdate ( changes : AggregateChanges , user : User ) : Promise < void > {
const executor = new PrismaBatchExecutor ( this . context , {
registry: this . registry ,
});
await executor . execute ( changes );
}
}
Repository
import { PrismaRepository , PrismaUnitOfWork } from "@woltz/rich-domain-prisma" ;
class UserRepository extends PrismaRepository < User , UserRecord > {
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 < User | null > {
const data = await this . context . user . findUnique ({
where: { email },
include: this . includes ,
});
return data ? this . toDomainMapper . build ( data ) : null ;
}
}
Use Case
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 < User > {
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 < User > {
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:
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 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
// Unit of Work
export { PrismaUnitOfWork , Transactional , getCurrentPrismaContext };
export type { PrismaTransactionContext , PrismaClientLike , PrismaTransactionClient };
// Repository
export { PrismaRepository };
export type { PrismaRepositoryConfig };
// Mapper
export { PrismaToPersistence };
// Batch Executor
export { PrismaBatchExecutor , executeBatch };
export type { BatchExecutorConfig };
// Errors
export {
PrismaRepositoryError ,
ModelNotFoundError ,
TableNotFoundError ,
NoRecordsAffectedError ,
BatchOperationError ,
};
PrismaUnitOfWork Methods
Method Returns Description transaction(work)Promise<T>Execute work in transaction isInTransaction()booleanCheck if in transaction getCurrentContext()PrismaTransactionContext | nullGet current context
PrismaRepository Methods
Method Returns Description find(criteria)Promise<PaginatedResult<T>>Find with criteria findById(id)Promise<T | null>Find by ID findManyByIds(ids)Promise<T[]>Find multiple by IDs count(criteria?)Promise<number>Count entities exists(id)Promise<boolean>Check if exists save(entity)Promise<void>Create or update delete(entity)Promise<void>Delete entity deleteById(id)Promise<void>Delete by ID transaction(work)Promise<T>Execute in transaction