Skip to main content
For a complete working example, see the fastify-with-prisma example in the repository.

Installation

npm install @woltz/rich-domain @woltz/rich-domain-prisma @prisma/client zod
npm install -D prisma

Setup Prisma

Initialize and configure Prisma:
npx prisma init
Define your schema in prisma/schema.prisma:
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:
npx prisma generate
npx prisma db push

Initialize Prisma Client

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

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

import { Prisma } from "@prisma/client";

export type UserSchema = Prisma.UserGetPayload<{
  include: {
    posts: {
      include: {
        tagPosts: {
          include: {
            tag: true;
          };
        };
      };
    };
  };
}>;

Create Mappers

ToDomain Mapper

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

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.
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

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

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:
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 for more details.

Next Steps