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

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

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

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