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

Installation

npm install @woltz/rich-domain @woltz/rich-domain-drizzle drizzle-orm pg zod
npm install -D drizzle-kit @types/pg

Define the Database Schema

Create src/infrastructure/database/schema.ts:
import {
  pgTable,
  uuid,
  text,
  boolean,
  timestamp,
  primaryKey,
} from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";

export const users = pgTable("users", {
  id: uuid("id").primaryKey(),
  email: text("email").notNull().unique(),
  name: text("name").notNull(),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

export const posts = pgTable("posts", {
  id: uuid("id").primaryKey(),
  title: text("title").notNull(),
  content: text("content").notNull(),
  published: boolean("published").notNull().default(false),
  authorId: uuid("author_id")
    .notNull()
    .references(() => users.id, { onDelete: "cascade" }),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

export const tags = pgTable("tags", {
  id: uuid("id").primaryKey(),
});

// Junction table for N:N (Post ↔ Tag)
export const postsToTags = pgTable(
  "posts_to_tags",
  {
    postId: uuid("post_id")
      .notNull()
      .references(() => posts.id, { onDelete: "cascade" }),
    tagId: uuid("tag_id")
      .notNull()
      .references(() => tags.id, { onDelete: "cascade" }),
  },
  (t) => [primaryKey({ columns: [t.postId, t.tagId] })]
);

// Relations for Drizzle relational query API
export const usersRelations = relations(users, ({ many }) => ({
  posts: many(posts),
}));

export const postsRelations = relations(posts, ({ one, many }) => ({
  author: one(users, { fields: [posts.authorId], references: [users.id] }),
  tags: many(postsToTags),
}));

export const postsToTagsRelations = relations(postsToTags, ({ one }) => ({
  post: one(posts, { fields: [postsToTags.postId], references: [posts.id] }),
  tag: one(tags, { fields: [postsToTags.tagId], references: [tags.id] }),
}));

// Inferred types for mappers
export type UserRecord = typeof users.$inferSelect;
export type PostRecord = typeof posts.$inferSelect & {
  tags?: Array<{ tag: typeof tags.$inferSelect }>;
};
export type UserWithPosts = UserRecord & { posts: PostRecord[] };

Initialize the Database

Create src/infrastructure/database/db.ts:
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";

let pool: Pool | null = null;
let db: ReturnType<typeof drizzle<typeof schema>> | null = null;

export async function initializeDatabase() {
  pool = new Pool({ connectionString: process.env.DATABASE_URL });
  await pool.query("SELECT 1"); // verify connection
  db = drizzle(pool, { schema });
}

export function getDb() {
  if (!db) throw new Error("Database not initialized.");
  return db;
}

export async function closeDatabase() {
  await pool?.end();
  db = null;
  pool = null;
}

Define Domain Models

import { z } from "zod";
import { Aggregate, Entity, EntityValidation, Id } from "@woltz/rich-domain";

// Tag — independent reference entity
export class Tag extends Entity<{ id: Id }> {}

// Post — owned entity (belongs to User)
const postSchema = z.object({
  id: z.custom<Id>(),
  title: z.string().min(1),
  content: z.string().min(1),
  published: z.boolean(),
  authorId: z.string(),
  tags: z.array(z.instanceof(Tag)),
  createdAt: z.date(),
  updatedAt: z.date(),
});

export type PostProps = z.infer<typeof postSchema>;

export class Post extends Aggregate<PostProps> {
  protected static validation: EntityValidation<PostProps> = {
    schema: postSchema,
  };

  static restore(props: PostProps): Post { return new Post(props); }

  addTag(tag: Tag) { this.props.tags.push(tag); }

  removeTag(tag: Tag) {
    this.props.tags = this.props.tags.filter((t) => !t.id.equals(tag.id));
  }

  publish() { this.props.published = 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; }
  get createdAt() { return this.props.createdAt; }
  get updatedAt() { return this.props.updatedAt; }
}

// User — aggregate root
const userSchema = z.object({
  id: z.custom<Id>(),
  email: z.string().email(),
  name: z.string().min(1),
  posts: z.array(z.instanceof(Post)),
  createdAt: z.date(),
  updatedAt: z.date(),
});

export type UserProps = z.infer<typeof userSchema>;

export class User extends Aggregate<UserProps> {
  protected static validation: EntityValidation<UserProps> = {
    schema: userSchema,
  };

  static create(props: Omit<UserProps, "id" | "createdAt" | "updatedAt">): User {
    return new User({ ...props, createdAt: new Date(), updatedAt: new Date() });
  }

  static restore(props: UserProps): User { return new User(props); }

  addPost(post: Post) { this.props.posts.push(post); }

  updateName(name: string) {
    this.props.name = name;
    this.props.updatedAt = new Date();
  }

  get email()     { return this.props.email; }
  get name()      { return this.props.name; }
  get posts()     { return this.props.posts; }
  get createdAt() { return this.props.createdAt; }
  get updatedAt() { return this.props.updatedAt; }
}

Create Mappers

ToDomain Mapper

import { Mapper, Id } from "@woltz/rich-domain";
import { UserWithPosts } from "../schema";

export class UserToDomainMapper extends Mapper<UserWithPosts, User> {
  build(record: UserWithPosts): User {
    return User.restore({
      id: new Id(record.id),
      email: record.email,
      name: record.name,
      createdAt: record.createdAt,
      updatedAt: record.updatedAt,
      posts: (record.posts ?? []).map((p) =>
        Post.restore({
          id: new Id(p.id),
          title: p.title,
          content: p.content,
          published: p.published,
          authorId: p.authorId,
          createdAt: p.createdAt,
          updatedAt: p.updatedAt,
          tags: (p.tags ?? []).map(({ tag }) => new Tag({ id: new Id(tag.id) })),
        })
      ),
    });
  }
}

ToPersistence Mapper

import { EntitySchemaRegistry } from "@woltz/rich-domain";
import { DrizzleToPersistence, DrizzleUnitOfWork, Transactional } from "@woltz/rich-domain-drizzle";
import { users, posts, tags, postsToTags } from "../schema";

export const userRegistry = new EntitySchemaRegistry()
  .register({
    entity: "User",
    table: "users",
    collections: {
      posts: { type: "owned", entity: "Post" },
    },
  })
  .register({
    entity: "Post",
    table: "posts",
    parentFk: { field: "authorId", parentEntity: "User" },
    collections: {
      tags: {
        type: "reference",
        entity: "Tag",
        junction: {
          table: "posts_to_tags", // must match tableMap key
          sourceKey: "postId",
          targetKey: "tagId",
        },
      },
    },
  })
  .register({ entity: "Tag", table: "tags" });

type DB = ReturnType<typeof getDb>;

export class UserToPersistenceMapper extends DrizzleToPersistence<User, DB> {
  protected readonly registry = userRegistry;

  protected readonly tableMap = new Map<string, any>([
    ["User", users],
    ["Post", posts],
    ["Tag", tags],
    ["posts_to_tags", postsToTags], // junction table
  ]);

  constructor(db: DB, uow: DrizzleUnitOfWork) {
    super(db, uow);
  }

  @Transactional()
  protected async onCreate(user: User): Promise<void> {
    // Insert aggregate root
    await this.context.insert(users).values({
      id: user.id.value,
      email: user.email,
      name: user.name,
      createdAt: user.createdAt,
      updatedAt: user.updatedAt,
    });

    // Insert owned children (Posts)
    if (user.posts.length > 0) {
      await this.context.insert(posts).values(
        user.posts.map((p) => ({
          id: p.id.value,
          title: p.title,
          content: p.content,
          published: p.published,
          authorId: user.id.value,
          createdAt: p.createdAt,
          updatedAt: p.updatedAt,
        }))
      );
    }
  }
  // onUpdate is handled automatically by DrizzleBatchExecutor
}

Create Repository

import { DrizzleRepository, DrizzleUnitOfWork, SearchableField } from "@woltz/rich-domain-drizzle";
import { eq } from "drizzle-orm";
import { users, UserWithPosts } from "../schema";

type DB = ReturnType<typeof getDb>;

export class UserRepository extends DrizzleRepository<User, UserWithPosts, DB> {
  constructor(db: DB, uow: DrizzleUnitOfWork) {
    super({
      db,
      table: users,
      toDomainMapper: new UserToDomainMapper(),
      toPersistenceMapper: new UserToPersistenceMapper(db, uow),
      uow,
    });
  }

  protected get model() {
    return "users"; // key in db.query — matches Drizzle schema export name
  }

  protected getSearchableFields(): SearchableField<UserWithPosts>[] {
    return ["name", "email"];
  }

  protected getDefaultRelations() {
    return {
      posts: { with: { tags: { with: { tag: true } } } },
    };
  }

  async findByEmail(email: string): Promise<User | null> {
    const record = await this.context.query.users.findFirst({
      where: eq(users.email, email),
      with: this.getDefaultRelations(),
    });
    if (!record) return null;
    const user = this.toDomainMapper.build(record as any);
    user.markAsClean();
    return user;
  }
}

Usage

Setup UnitOfWork and Repository

import { DrizzleUnitOfWork } from "@woltz/rich-domain-drizzle";

await initializeDatabase();

const db = getDb();
const uow = new DrizzleUnitOfWork(db);
const userRepo = new UserRepository(db, uow);

Create

import { Criteria, Id } from "@woltz/rich-domain";

// Create user (posts: [] on creation)
const user = User.create({ name: "John Doe", email: "john@example.com", posts: [] });
await userRepo.save(user);
// ✅ User inserted into DB

Create with Nested Entities

// User.restore() pre-embeds posts — onCreate handles both in one transaction
const userId = new Id();
const post = Post.restore({
  id: new Id(), title: "Hello World", content: "First post",
  published: false, authorId: userId.value,
  tags: [], createdAt: new Date(), updatedAt: new Date(),
});

const user = User.restore({
  id: userId, name: "John", email: "john@example.com",
  posts: [post], createdAt: new Date(), updatedAt: new Date(),
});

await userRepo.save(user);
// ✅ User and Post inserted atomically

Find with Criteria

// Filter + order + paginate (top-level columns only)
const criteria = Criteria.create<User>()
  .whereEquals("name", "John")
  .orderByAsc("createdAt")
  .paginate(1, 10);

const result = await userRepo.find(criteria);
// result.data        → User[]
// result.toJSON().meta.total → total count
// result.toJSON().meta.hasNext → boolean

Update

const user = await userRepo.findById(userId);
user.updateName("Jane Doe"); // tracked by ChangeTracker
await userRepo.save(user);
// ✅ Only changed fields are updated in DB

Connect / Disconnect (N:N Reference)

// Connect — adds row to posts_to_tags
const post = await postRepo.findById(postId);
post.addTag(new Tag({ id: new Id(tagId) }));
await postRepo.save(post);

// Disconnect — removes row from posts_to_tags
post.removeTag(new Tag({ id: new Id(tagId) }));
await postRepo.save(post);

Remove Owned Entity from Collection

const user = await userRepo.findById(userId);
const index = user.posts.findIndex((p) => p.id.value === postIdToRemove);
if (index !== -1) user.props.posts.splice(index, 1);
await userRepo.save(user);
// ✅ Post row is deleted from DB

Delete

await userRepo.delete(user);
await userRepo.deleteById(userId);

Transactions

await uow.transaction(async () => {
  const user = User.create({ name: "Alice", email: "alice@example.com", posts: [] });
  await userRepo.save(user);

  const post = new Post({ ... });
  await postRepo.save(post);
  // Both committed atomically, or both rolled back on error
});

Key Differences from Prisma

TopicPrismaDrizzle
N:N junctionOptional (Prisma manages implicit tables)Always required
onCreateNested writes via Prisma APIManual INSERT with Drizzle SQL
Criteria dot pathsSupported via includeNot supported — use custom methods
Relational queriesinclude: { posts: true }with: { posts: true } (relational API)
contains operatorCase-insensitive (all DBs)Uses ILIKE (PostgreSQL only)

Limitations

Criteria dot-field paths are not supported. Filters, ordering, and search fields must reference top-level columns of the primary table. Using "profile.name", "posts.title", or any dotted path throws a DrizzleAdapterError. Add custom repository methods with explicit JOINs for cross-table queries.

Next Steps

Drizzle Integration

Deep dive into all Drizzle adapter features

Repository Pattern

Learn advanced repository patterns

Change Tracking

Understand how changes are tracked

Schema Registry

Full Schema Registry reference