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, ValueObject, EntityValidation, Id } from "@woltz/rich-domain";
// Tag (Value Object - shared, immutable reference)
const tagSchema = z.object({
id: z.custom<Id>((val) => val instanceof Id),
});
type TagProps = z.infer<typeof tagSchema>;
export class Tag extends ValueObject<TagProps> {
get id() {
return this.props.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 =>
Post.restore({
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(user: User, changes: AggregateChanges): Promise<void> {
const executor = new PrismaBatchExecutor(this.context, {
registry: this.registry,
rootId: user.id.value,
// Custom data mappers per entity.
// Use this to transform domain objects to persistence format. (optional)
dataMappers: {
Post: (item) => {
(...)
}
}
});
// 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.