Skip to main content

Overview

@woltz/rich-domain-prisma provides seamless integration between rich-domain and Prisma ORM. It leverages Prisma’s nested writes and transaction support to align perfectly with rich-domain’s change tracking and Unit of Work patterns.
npm install @woltz/rich-domain @woltz/rich-domain-prisma

Unit of Work

Request-isolated transactions with AsyncLocalStorage

Repository Base Class

PrismaRepository with built-in Criteria support

Change Tracking

PrismaToPersistence with automatic change detection

Batch Operations

PrismaBatchExecutor for efficient bulk writes

Transactional Outbox

Optional outboxStore on the repository for guaranteed event delivery
For a complete working example, see the fastify-with-prisma example in the repository.

Quick Start

1. Setup

import { PrismaClient } from "@prisma/client";
import { PrismaUnitOfWork } from "@woltz/rich-domain-prisma";

const prisma = new PrismaClient();
const uow = new PrismaUnitOfWork(prisma);

2. Create Repository

import { PrismaRepository } from "@woltz/rich-domain-prisma";

class UserRepository extends PrismaRepository<User, UserRecord> {
  protected readonly model = "user";
  protected readonly includes = { posts: true, profile: true };

  constructor(prisma: PrismaClient, uow: PrismaUnitOfWork) {
    super(
      new UserToPersistenceMapper(prisma, uow),
      new UserToDomainMapper(),
      prisma,
      uow
    );
  }
}

3. Use It

const userRepository = new UserRepository(prisma, uow);

// Create
const user = new User({
  name: "John",
  email: "john@example.com",
  posts: [],
});
await userRepository.save(user);

// Find with Criteria
const criteria = Criteria.create<User>()
  .where("name", "contains", "John")
  .orderBy("createdAt", "desc")
  .paginate(1, 10);

const result = await userRepository.find(criteria);

// Update with change tracking
const found = await userRepository.findById(user.id.value);
found.name = "John Updated";
found.posts.push(new Post({ title: "Hello", content: "World" }));
await userRepository.save(found); // Only changed data is persisted

// Delete
await userRepository.delete(found);

PrismaUnitOfWork

Manages transactions with per-request isolation using AsyncLocalStorage.
import { PrismaUnitOfWork } from "@woltz/rich-domain-prisma";

const uow = new PrismaUnitOfWork(prisma);

Transaction Execution

// Execute multiple operations atomically
await uow.transaction(async () => {
  await userRepository.save(user);
  await orderRepository.save(order);
  await notificationRepository.save(notification);
  // All or nothing - auto rollback on failure
});

Request Isolation

Each HTTP request gets its own transaction context, preventing cross-request interference:
// Request 1
app.post("/users", async (req, res) => {
  await uow.transaction(async () => {
    // This transaction is isolated to Request 1
    await userRepository.save(user);
  });
});

// Request 2 (concurrent)
app.get("/users/:id", async (req, res) => {
  // NOT affected by Request 1's transaction
  const user = await userRepository.findById(req.params.id);
});

API Reference

MethodDescription
transaction(work)Execute work function in a transaction
isInTransaction()Check if currently in a transaction
getCurrentContext()Get current transaction context or null

@Transactional Decorator

Decorator that automatically wraps a method in a transaction.
import { Transactional } from "@woltz/rich-domain-prisma";

class CreateUserUseCase {
  constructor(
    private readonly userRepository: UserRepository,
    private readonly uow: PrismaUnitOfWork
  ) {}

  @Transactional()
  async execute(input: CreateUserInput): Promise<User> {
    // Everything here runs in a transaction automatically
    const user = new User({ ...input, posts: [] });
    await this.userRepository.save(user);
    return user;
  }
}

With Explicit UoW Parameter

You can pass the UoW instance directly to the decorator instead of relying on constructor injection:
class CreateUserUseCase {
  constructor(private readonly userRepository: UserRepository) {}

  @Transactional(myUnitOfWork)
  async execute(input: CreateUserInput): Promise<User> {
    // Uses the explicitly provided UoW
    const user = new User({ ...input, posts: [] });
    await this.userRepository.save(user);
    return user;
  }
}

UoW Resolution Order

The decorator looks for the UoW instance in this order:
  1. Decorator parameter - @Transactional(myUow)
  2. Instance property - this.uow
  3. Private property - this._uow
  4. Any property - Any property that is a PrismaUnitOfWork instance
// Option 1: Via decorator parameter
@Transactional(globalUow)
async method1() { ... }

// Option 2: Via constructor (recommended)
class MyService {
  constructor(private readonly uow: PrismaUnitOfWork) {}

  @Transactional()
  async method2() { ... }
}

// Option 3: Via any instance property
class MyService {
  unitOfWork = new PrismaUnitOfWork(prisma);

  @Transactional()
  async method3() { ... }
}

Behavior

ScenarioBehavior
Direct callCreates new transaction
Already in transactionReuses existing one
Error thrownAutomatic rollback

PrismaRepository

Base class for repositories with full Criteria support.
import { PrismaRepository } from "@woltz/rich-domain-prisma";

abstract class PrismaRepository<TDomain, TPersistence, TContext = PrismaClientLike> {
  // Required: Prisma model name
  protected abstract get model(): string;

  // Required: Search query generator
  protected abstract generateSearchQuery(search: string): any[];

  // Optional: relations to include
  protected readonly includes: Record<string, any> = {};

  // Built-in methods
  async find(criteria: Criteria<TDomain>): Promise<PaginatedResult<TDomain>>;
  async findById(id: string): Promise<TDomain | null>;
  async findManyByIds(ids: string[]): Promise<TDomain[]>;
  async count(criteria?: Criteria<TDomain>): Promise<number>;
  async exists(id: string): Promise<boolean>;
  async save(entity: TDomain): Promise<void>;
  async delete(entity: TDomain): Promise<void>;
  async deleteById(id: string): Promise<void>;
  async transaction<T>(work: () => Promise<T>): Promise<T>;
}

Complete Implementation

import {
  PrismaRepository,
  PrismaUnitOfWork,
  PrismaOutboxStore,
} from "@woltz/rich-domain-prisma";

class UserRepository extends PrismaRepository<User, UserRecord> {
  protected readonly model = "user";
  protected readonly includes = {
    posts: true,
    profile: true,
  };

  constructor(
    prisma: PrismaClient,
    uow: PrismaUnitOfWork,
    outboxStore?: PrismaOutboxStore
  ) {
    super(
      new UserToPersistenceMapper(prisma, uow),
      new UserToDomainMapper(),
      prisma,
      uow,
      outboxStore
    );
  }
  
  // Required: implement search query generation
  protected generateSearchQuery(search: string): any[] {
    return [
      { name: { contains: search, mode: "insensitive" } },
      { email: { contains: search, mode: "insensitive" } },
    ];
  }

  // Custom query methods
  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;
  }
}

Context Awareness

The repository automatically uses the transaction context when available:
class UserRepository extends PrismaRepository<User, UserRecord> {
  // Use this.context for transaction-aware queries
  async customQuery(): Promise<User[]> {
    const data = await this.context.user.findMany({
      where: { status: "active" },
      include: this.includes,
    });
    return data.map((d) => this.toDomainMapper.build(d));
  }
}

Transactional Outbox

Pass an optional outboxStore as the fifth argument to PrismaRepository’s constructor. When set, save() automatically persists uncommitted domain events to the outbox table in the same database transaction as the aggregate write.
PropertyTypeRequiredDescription
outboxStorePrismaOutboxStoreNoEnables auto-save of domain events on save()
Use PrismaOutboxStore from @woltz/rich-domain-prisma. For the full setup (table migration, event bus decorator, background publisher), see Transactional Outbox.
import { PrismaOutboxStore } from "@woltz/rich-domain-prisma";

const outboxStore = new PrismaOutboxStore(prisma);

class OrderRepository extends PrismaRepository<Order, OrderRecord> {
  constructor(prisma: PrismaClient, uow: PrismaUnitOfWork) {
    super(
      new OrderToPersistenceMapper(prisma, uow),
      new OrderToDomainMapper(),
      prisma,
      uow,
      outboxStore // ← events saved atomically with the aggregate
    );
  }
}

EntitySchemaRegistry

Maps domain entities to database tables, handles field mapping, and configures relationships.
See the complete Schema Registry documentation for all features.

Basic Registration

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

const registry = new EntitySchemaRegistry()
  .register({
    entity: "User",
    table: "user",
  })
  .register({
    entity: "Post",
    table: "post",
    fields: {
      content: "main_content", // domain → database mapping
    },
    parentFk: {
      field: "authorId",
      parentEntity: "User",
    },
  });

Collection Configuration (N:N Relations)

For N:N relationships, configure collections with type: "reference":
const 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",
          // Optional: set when the Prisma relation field name differs from the domain property name.
          // e.g. domain property: "tags", Prisma schema field: "post_tags"
          // relationName: "post_tags",
          // Junction config required for manually created pivot tables
          junction: {
            table: "tagPost",    // Pivot table name
            sourceKey: "postId", // FK to Post
            targetKey: "tagId",  // FK to Tag
          },
        },
      },
    });

Collection Types

TypeRelationshipBatch Behavior
owned1:NcreateMany / deleteMany
referenceN:Nconnect / disconnect
The PrismaBatchExecutor automatically uses the correct Prisma operations based on collection type:
// For 'owned' collections (1:N):
// - Creates use: prisma.post.createMany({ data: [...] })
// - Deletes use: prisma.post.deleteMany({ where: { id: { in: [...] } } })

// For 'reference' collections (N:N):
// - Creates use: prisma.post.update({ data: { tags: { connect: [...] } } })
// - Deletes use: prisma.post.update({ data: { tags: { disconnect: [...] } } })
// Note: if the Prisma relation field name differs from the domain property name,
// set `relationName` in the collection config so the executor uses the correct field.

PrismaToPersistence

Base mapper class with change tracking integration.
import { PrismaToPersistence } from "@woltz/rich-domain-prisma";
import { EntitySchemaRegistry, AggregateChanges } from "@woltz/rich-domain";

abstract class PrismaToPersistence<TDomain, PrismaClient = PrismaClientLike> extends Mapper<TDomain, void> {
  // Required: registry for field mapping
  protected abstract readonly registry: EntitySchemaRegistry;

  // Required: handle entity creation
  protected abstract onCreate(entity: TDomain): Promise<void>;

  // Optional: override for custom update logic (default uses PrismaBatchExecutor)
  protected async onUpdate(
    changes: AggregateChanges,
    entity: TDomain
  ): Promise<void>;

  // Returns transaction client when inside a transaction, otherwise the prisma client
  protected get context(): PrismaClient;
}

Complete Example

class UserToPersistenceMapper extends PrismaToPersistence<User> {
  protected readonly registry = new EntitySchemaRegistry()
    .register({ 
      entity: "User", 
      table: "user",
      collections: {
        posts: { type: "owned" },
        tags: { type: "reference", entity: "Tag" },
      },
    })
    .register({
      entity: "Post",
      table: "post",
      parentFk: { field: "authorId", parentEntity: "User" },
    });

  protected async onCreate(user: User): Promise<void> {
    await this.context.user.create({
      data: {
        id: user.id.value,
        name: user.name,
        email: user.email,
        posts: {
          createMany: {
            data: user.posts.map((p) => ({
              id: p.id.value,
              title: p.title,
              content: p.content,
              authorId: user.id.value,
            })),
          },
        },
        // N:N - connect existing tags
        tags: {
          connect: user.tags.map((t) => ({ id: t.id.value })),
        },
      },
    });
  }

  // onUpdate uses PrismaBatchExecutor by default — override only if needed
}
When overriding, use changes.without() to persist one entity manually and pass the remaining changes to super.onUpdate().

PrismaBatchExecutor

Executes batch operations from AggregateChanges with proper ordering and relationship handling.
import { PrismaBatchExecutor } from "@woltz/rich-domain-prisma";

const executor = new PrismaBatchExecutor(context, {
  registry: schemaRegistry,
});

await executor.execute(changes);

Execution Order

The executor respects referential integrity:
  1. Deletes - Leaf → Root (depth DESC)
    • owned: Uses deleteMany
    • reference: Uses disconnect
  2. Creates - Root → Leaf (depth ASC)
    • owned: Uses createMany
    • reference: Uses connect
  3. Updates - Any order
Batch executors and repositories resolve the correct PK column via registry.getPrimaryKeyField() and registry.buildWhereById(). Configure primaryKey when a table does not use an id column (e.g. factoryProfile.factoryId). Remember: primaryKey names the database column; the value always comes from entity.id, not from a domain property with the same name.

Convenience Function

import { executeBatch } from "@woltz/rich-domain-prisma";

// Shorthand for simple cases
await executeBatch(context, changes, {
  registry: schemaRegistry,
});

Complete Example

Domain Model

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

// Post Entity
const postSchema = z.object({
  id: z.custom<Id>((v) => v instanceof Id),
  title: z.string().min(1),
  content: z.string(),
  published: z.boolean().default(false),
});

class Post extends Entity<z.infer<typeof postSchema>> {
  protected static validation: EntityValidation<z.infer<typeof postSchema>> = {
    schema: postSchema,
  };

  get title() { return this.props.title; }
  get content() { return this.props.content; }
  get published() { return this.props.published; }

  publish() {
    this.props.published = true;
  }
}

// Tag Entity (for N:N reference)
class Tag extends Entity<{ id: Id; name: string }> {
  get name() { return this.props.name; }
}

// User Aggregate
const userSchema = z.object({
  id: z.custom<Id>((v) => v instanceof Id),
  name: z.string().min(2),
  email: z.string().email(),
  posts: z.array(z.custom<Post>((v) => v instanceof Post)),
  tags: z.array(z.custom<Tag>((v) => v instanceof Tag)),
});

class User extends Aggregate<z.infer<typeof userSchema>> {
  protected static validation = { schema: userSchema };

  get name() { return this.props.name; }
  set name(value: string) { this.props.name = value; }
  get email() { return this.props.email; }
  get posts() { return this.props.posts; }
  get tags() { return this.props.tags; }

  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);
  }

  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);
  }
}

Schema Registry

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

const schemaRegistry = new EntitySchemaRegistry()
  .register({
    entity: "User",
    table: "user",
    collections: {
      posts: { type: "owned" },
      tags: { type: "reference", entity: "Tag" },
    },
  })
  .register({
    entity: "Post",
    table: "post",
    parentFk: { field: "authorId", parentEntity: "User" },
  })
  .register({
    entity: "Tag",
    table: "tag",
  });

Mappers

import { Mapper } from "@woltz/rich-domain";
import { PrismaToPersistence, PrismaBatchExecutor } from "@woltz/rich-domain-prisma";

// Domain Mapper
class UserToDomainMapper extends Mapper<UserRecord, User> {
  build(record: UserRecord): User {
    return new User({
      id: Id.from(record.id),
      name: record.name,
      email: record.email,
      posts: record.posts.map(
        (p) => new Post({
          id: Id.from(p.id),
          title: p.title,
          content: p.content,
          published: p.published,
        })
      ),
      tags: record.tags.map(
        (t) => new Tag({ id: Id.from(t.id), name: t.name })
      ),
    });
  }
}

// Persistence Mapper
class UserToPersistenceMapper extends PrismaToPersistence<User> {
  protected readonly registry = schemaRegistry;

  protected async onCreate(user: User): Promise<void> {
    await this.context.user.create({
      data: {
        id: user.id.value,
        name: user.name,
        email: user.email,
        posts: {
          createMany: {
            data: user.posts.map((p) => ({
              id: p.id.value,
              title: p.title,
              content: p.content,
              published: p.published,
              authorId: user.id.value,
            })),
          },
        },
        tags: {
          connect: user.tags.map((t) => ({ id: t.id.value })),
        },
      },
    });
  }

  // onUpdate uses PrismaBatchExecutor by default — override only if needed
}

Repository

import {
  PrismaRepository,
  PrismaUnitOfWork,
  PrismaOutboxStore,
} from "@woltz/rich-domain-prisma";

class UserRepository extends PrismaRepository<User, UserRecord> {
  protected readonly model = "user";
  protected readonly includes = { posts: true, tags: true };

  constructor(prisma: PrismaClient, uow: PrismaUnitOfWork) {
    super(
      new UserToPersistenceMapper(prisma, uow),
      new UserToDomainMapper(),
      prisma,
      uow
    );
  }

  protected generateSearchQuery(search: string): any[] {
    return [
      { name: { contains: search, mode: "insensitive" } },
      { email: { contains: search, mode: "insensitive" } },
    ];
  }

  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;
  }
}

Use Case

import { Transactional } from "@woltz/rich-domain-prisma";
import { EntityNotFoundError } from "@woltz/rich-domain";

class UserService {
  constructor(
    private readonly userRepository: UserRepository,
    private readonly uow: PrismaUnitOfWork
  ) {}

  @Transactional()
  async addTagToUser(userId: string, tag: Tag): Promise<User> {
    const user = await this.userRepository.findById(userId);
    if (!user) {
      throw new EntityNotFoundError("User", userId);
    }

    user.addTag(tag); // N:N reference
    await this.userRepository.save(user); // Uses connect

    return user;
  }

  @Transactional()
  async removeTagFromUser(userId: string, tagId: string): Promise<User> {
    const user = await this.userRepository.findById(userId);
    if (!user) {
      throw new EntityNotFoundError("User", userId);
    }

    user.removeTag(Id.from(tagId)); // N:N reference
    await this.userRepository.save(user); // Uses disconnect

    return user;
  }
}

Error Handling

The adapter provides specific error types:
import {
  PrismaRepositoryError,
  ModelNotFoundError,
  TableNotFoundError,
  NoRecordsAffectedError,
  BatchOperationError,
} from "@woltz/rich-domain-prisma";

try {
  await userRepository.save(user);
} catch (error) {
  if (error instanceof ModelNotFoundError) {
    // Model not in Prisma schema
  } else if (error instanceof TableNotFoundError) {
    // Entity not in registry
  } else if (error instanceof BatchOperationError) {
    // Batch operation failed
  }
}

Complete Example

See the fastify-with-prisma example for a complete working application demonstrating:
  • User aggregate with Posts (1:N owned)
  • Post with Tags (N:N reference via junction table)
  • Case-insensitive search
  • Transaction management
  • CRUD operations
  • Domain events with BullMQ

API Reference

Exports

// Unit of Work
export { PrismaUnitOfWork, Transactional, getCurrentPrismaContext };
export type { PrismaTransactionContext, PrismaClientLike, PrismaTransactionClient };

// Repository
export { PrismaRepository };
export type { PrismaRepositoryConfig };

// Outbox
export { PrismaOutboxStore, PRISMA_OUTBOX_SCHEMA };

// Mapper
export { PrismaToPersistence };

// Batch Executor
export { PrismaBatchExecutor, executeBatch };
export type { BatchExecutorConfig };

// Errors
export {
  PrismaRepositoryError,
  ModelNotFoundError,
  TableNotFoundError,
  NoRecordsAffectedError,
  BatchOperationError,
  OutboxStoreError,
};

PrismaRepositoryConfig

interface PrismaRepositoryConfig {
  prisma: PrismaClientLike;
  uow: PrismaUnitOfWork;
  /** Optional — auto-save domain events on `save()`. See [Transactional Outbox](/integrations/outbox). */
  outboxStore?: PrismaOutboxStore;
}
The repository constructor accepts the same options positionally: mappers, prisma, uow, and optional outboxStore.

PrismaUnitOfWork Methods

MethodReturnsDescription
transaction(work)Promise<T>Execute work in transaction
isInTransaction()booleanCheck if in transaction
getCurrentContext()PrismaTransactionContext | nullGet current context

PrismaRepository Methods

MethodReturnsDescription
find(criteria)Promise<PaginatedResult<T>>Find with criteria
findById(id)Promise<T | null>Find by ID
findManyByIds(ids)Promise<T[]>Find multiple by IDs
count(criteria?)Promise<number>Count entities
exists(id)Promise<boolean>Check if exists
save(entity)Promise<void>Create or update
delete(entity)Promise<void>Delete entity
deleteById(id)Promise<void>Delete by ID
transaction(work)Promise<T>Execute in transaction