For a complete working example, see the fastify-with-prisma example in the repository.
Installation
Copy
npm install @woltz/rich-domain @woltz/rich-domain-prisma @prisma/client zod
npm install -D prisma
Setup Prisma
Initialize and configure Prisma:Copy
npx prisma init
prisma/schema.prisma:
Copy
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])
}
Copy
npx prisma generate
npx prisma db push
Initialize Prisma Client
Copy
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
Copy
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<Id>((val) => val instanceof Id),
title: z.string().min(3),
content: z.string(),
published: z.boolean(),
authorId: z.string(),
tags: z.array(z.custom<Tag>()),
createdAt: z.date(),
updatedAt: z.date(),
});
type PostProps = z.infer<typeof postSchema>;
export class Post extends Entity<PostProps> {
protected static validation: EntityValidation<PostProps> = {
schema: postSchema,
config: { onCreate: true, onUpdate: 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;
}
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<Id>((val) => val instanceof Id),
email: z.string().email(),
name: z.string().min(2),
posts: z.array(z.custom<Post>()),
createdAt: z.date(),
updatedAt: z.date(),
});
type UserProps = z.infer<typeof userSchema>;
export class User extends Aggregate<UserProps> {
protected static validation: EntityValidation<UserProps> = {
schema: userSchema,
config: { onCreate: true, onUpdate: true, throwOnError: true },
};
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
Copy
import { Prisma } from "@prisma/client";
export type UserSchema = Prisma.UserGetPayload<{
include: {
posts: {
include: {
tagPosts: {
include: {
tag: true;
};
};
};
};
};
}>;
Create Mappers
ToDomain Mapper
Copy
import { Mapper, Id } from "@woltz/rich-domain";
import { UserSchema } from "./schemas/user.schema";
export class UserToDomainMapper extends Mapper<UserSchema, User> {
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
Copy
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<User, PrismaClient> {
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<void> {
// 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,
});
}
}
}
protected async onUpdate(changes: AggregateChanges, user: User): Promise<void> {
const executor = new PrismaBatchExecutor(this.context, {
registry: this.registry,
});
// Automatically handles:
// - Root updates (User fields)
// - Owned entity changes (Post creates/updates/deletes)
// - Reference changes (Tag link/unlink in junction table)
await executor.execute(changes);
}
}
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.
- 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
Copy
import { PrismaRepository } from "@woltz/rich-domain-prisma";
import { WriteAndRead } from "@woltz/rich-domain";
import { Prisma } from "@prisma/client";
interface IUserRepository extends WriteAndRead<User> {
findByEmail(email: string): Promise<User | null>;
}
export class UserRepository
extends PrismaRepository<User, UserSchema, PrismaClient>
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<User | null> {
const data = await this.context.user.findUnique({
where: { email },
include: this.includes,
});
return data ? this.toDomainMapper.build(data) : null;
}
}
Usage
Copy
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<User>()
.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:Copy
npx rich-domain generate
- ✅ 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 for more details.
Next Steps
Prisma Integration
Deep dive into Prisma adapter features
Repository Pattern
Learn advanced repository patterns
Change Tracking
Understand how changes are tracked
CLI Reference
Complete CLI documentation