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
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({ uow: 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({ uow: 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({ uow: 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> {
  // 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 findOne(criteria: Criteria<TDomain>): Promise<TDomain | null>;
  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 } 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
    );
  }
  
  // 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.modelAccessor.findUnique({
      where: { email },
      include: this.includes,
    });
    return data ? this.mapperToDomain.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 as any).user.findMany({
      where: { status: "active" },
      include: this.includes,
    });
    return data.map((d) => this.mapperToDomain.build(d));
  }
}

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",
          // 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: [...] } } })

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

  // Required: handle entity update with changes
  protected abstract onUpdate(
    entity: TDomain,
    changes: AggregateChanges
  ): Promise<void>;

  // Available: current context (transaction or prisma)
  protected get context(): PrismaClient | Transaction;
}

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

  protected async onUpdate(
    user: User,
    changes: AggregateChanges
  ): Promise<void> {
    const executor = new PrismaBatchExecutor(this.context, {
      registry: this.registry,
    });

    await executor.execute(changes);
  }
}

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

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

  protected async onUpdate(user: User, changes: AggregateChanges): Promise<void> {
    const executor = new PrismaBatchExecutor(this.context, {
      registry: this.registry,
    });

    await executor.execute(changes);
  }
}

Repository

import { PrismaRepository, PrismaUnitOfWork } 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.modelAccessor.findUnique({
      where: { email },
      include: this.includes,
    });
    return data ? this.mapperToDomain.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 };

// Mapper
export { PrismaToPersistence };

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

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

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
findOne(criteria)Promise<T | null>Find first match
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