@woltz/rich-domain-prisma
v0.7.7
Published
Prisma integration for Rich Domain Library
Downloads
238
Maintainers
Readme
@woltz/rich-domain-prisma
Prisma adapter for @woltz/rich-domain. Provides plug and play integration between rich-domain and Prisma ORM.
Installation
npm install @woltz/rich-domain @woltz/rich-domain-prismaFeatures
- ✅ Unit of Work with AsyncLocalStorage (request isolation)
- ✅ PrismaRepository base class with Criteria support
- ✅ PrismaToPersistence base class with change tracking
- ✅ @Transactional decorator for automatic transactions
- ✅ BatchExecutor for batch change operations
- ✅ Zero config - works out of the box
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> {
protected readonly model = "user";
protected readonly includes = { posts: 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 = User.create({ name: "John", email: "[email protected]", posts: [] });
await userRepository.save(user);
// Find by ID
const found = await userRepository.findById(user.id.value);
// 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 (automatic change tracking)
found.updateName("John Updated");
found.addPost(new Post({ ... }));
await userRepository.save(found); // Detects and applies only what changed
// Delete
await userRepository.delete(found);API Reference
PrismaUnitOfWork
Manages transactions with per-request isolation using AsyncLocalStorage.
const uow = new PrismaUnitOfWork(prisma);
// Execute in transaction
await uow.transaction(async () => {
await userRepository.save(user);
await orderRepository.save(order);
// All or nothing - rolls back on failure
});
// Check if in transaction
uow.isInTransaction(); // boolean
// Get current context
uow.getCurrentContext(); // PrismaTransactionContext | nullRequest Isolation
AsyncLocalStorage ensures each HTTP request has its own transaction context:
// Request 1: starts transaction
await uow.transaction(async () => {
// ...
});
// Request 2: NOT affected by Request 1's transaction
await userRepository.findById(id); // Uses normal connectionPrismaRepository
Base class for repositories with Criteria support.
abstract class PrismaRepository<TDomain, TPersistence> {
// Required: Prisma model name
protected abstract get model(): string;
// Optional: relations to include
protected readonly includes: Record<string, any> = {};
// Available 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 Example
import { PrismaRepository, PrismaUnitOfWork } from "@woltz/rich-domain-prisma";
import { Criteria } from "@woltz/rich-domain";
class UserRepository extends PrismaRepository<User, UserPersistence> {
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
);
}
// Custom 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;
}
async findActiveUsers(): Promise<User[]> {
const criteria = Criteria.create<User>()
.where("status", "equals", "active")
.orderBy("createdAt", "desc");
const result = await this.find(criteria);
return result.data;
}
}PrismaToPersistence
Base class for persistence mappers with change tracking support.
abstract class PrismaToPersistence<
TDomain,
PrismaClient = PrismaClientLike,
> extends Mapper<TDomain, void> {
// Required: registry for field mapping
protected abstract readonly registry: EntitySchemaRegistry;
// Required: implement creation
protected abstract onCreate(entity: TDomain): Promise<void>;
// Required: implement update
protected abstract onUpdate(
changes: AggregateChanges,
entity: TDomain
): Promise<void>;
// Available: current context (transaction or prisma)
protected get context(): PrismaClient | Transaction;
}Complete Example
import type { PrismaClient } from "@prisma/client";
import {
PrismaToPersistence,
PrismaBatchExecutor,
} from "@woltz/rich-domain-prisma";
import { EntitySchemaRegistry, AggregateChanges } from "@woltz/rich-domain";
const schemaRegistry = new EntitySchemaRegistry()
.register({
entity: "User",
table: "user",
})
.register({
entity: "Post",
table: "post",
fields: {
content: "main_content", // Field with different name in database
},
parentFk: {
field: "authorId",
parentEntity: "User",
},
});
class UserToPersistenceMapper extends PrismaToPersistence<User, PrismaClient> {
protected readonly registry = schemaRegistry;
protected async onCreate(user: User): Promise<void> {
await this.context.user.create({
data: {
id: user.id.value,
email: user.email,
name: user.name,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
posts: user.posts.length
? {
createMany: {
data: user.posts.map((post) => ({
id: post.id.value,
title: post.title,
main_content: post.content,
published: post.published,
authorId: user.id.value,
createdAt: post.createdAt,
updatedAt: post.updatedAt,
})),
},
}
: undefined,
},
});
}
protected async onUpdate(
changes: AggregateChanges,
user: User
): Promise<void> {
const executor = new PrismaBatchExecutor(this.context, {
registry: this.registry,
});
await executor.execute(changes);
}
}@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 // Required!
) {}
@Transactional()
async execute(input: CreateUserInput): Promise<User> {
// Everything here runs in a transaction automatically
const existing = await this.userRepository.findByEmail(input.email);
if (existing) {
throw new Error("User already exists");
}
const user = User.create({ ...input, posts: [] });
await this.userRepository.save(user);
return user;
}
}Behavior
| Scenario | Behavior | | ---------------------- | ----------------------- | | Direct call | Creates new transaction | | Already in transaction | Reuses existing one | | Error thrown | Automatic rollback |
Requirements
The class must have a uow property of type PrismaUnitOfWork:
class MyService {
constructor(
private readonly uow: PrismaUnitOfWork // ✅ Found by decorator
) {}
@Transactional()
async myMethod() { ... }
}PrismaBatchExecutor
Executes batch operations from AggregateChanges.
import { PrismaBatchExecutor } from "@woltz/rich-domain-prisma";
const executor = new PrismaBatchExecutor(context, {
// Registry for table/field mapping
registry: schemaRegistry,
});
await executor.execute(changes);Execution Order
The executor respects the correct order for referential integrity:
- Deletes - Leaf → Root (depth DESC)
- Creates - Root → Leaf (depth ASC)
- Updates - Any order
The executor uses the registry's mapEntity() for creates and mapFields() for updates.
Recipes
Use Case with Transaction
class TransferMoneyUseCase {
constructor(
private readonly accountRepository: AccountRepository,
private readonly uow: PrismaUnitOfWork
) {}
@Transactional()
async execute(input: { from: string; to: string; amount: number }) {
const fromAccount = await this.accountRepository.findById(input.from);
const toAccount = await this.accountRepository.findById(input.to);
if (!fromAccount || !toAccount) {
throw new Error("Account not found");
}
fromAccount.withdraw(input.amount);
toAccount.deposit(input.amount);
await this.accountRepository.save(fromAccount);
await this.accountRepository.save(toAccount);
// If any operation fails, everything is rolled back
}
}Repository with Custom Methods
class OrderRepository extends PrismaRepository<Order> {
protected readonly model = "order";
protected readonly includes = {
items: true,
customer: true,
};
async findByCustomerId(customerId: string): Promise<Order[]> {
const data = await this.modelAccessor.findMany({
where: { customerId },
include: this.includes,
orderBy: { createdAt: "desc" },
});
return data.map((item) => this.mapperToDomain.build(item));
}
async findPendingOrders(): Promise<Order[]> {
const criteria = Criteria.create<Order>()
.where("status", "equals", "pending")
.where("createdAt", "greaterThan", subDays(new Date(), 7));
const result = await this.find(criteria);
return result.data;
}
}Mapper with Complex Relations
class OrderToPersistenceMapper extends PrismaToPersistence<
Order,
PrismaClient
> {
protected readonly registry = new EntitySchemaRegistry()
.register({ entity: "Order", table: "order" })
.register({
entity: "OrderItem",
table: "orderItem",
parentFk: { field: "orderId", parentEntity: "Order" },
})
.register({
entity: "OrderItemAddon",
table: "orderItemAddon",
parentFk: { field: "orderItemId", parentEntity: "OrderItem" },
});
protected async onCreate(order: Order): Promise<void> {
await this.context.order.create({
data: {
id: order.id.value,
customerId: order.customerId,
status: order.status,
total: order.total,
items: {
create: order.items.map((item) => ({
id: item.id.value,
productId: item.productId,
quantity: item.quantity,
price: item.price,
addons: {
create: item.addons.map((addon) => ({
id: addon.id.value,
name: addon.name,
price: addon.price,
})),
},
})),
},
},
});
}
protected async onUpdate(
changes: AggregateChanges,
order: Order
): Promise<void> {
const executor = new PrismaBatchExecutor(this.context, {
registry: this.registry,
});
await executor.execute(changes);
}
}Testing
Mocking the UnitOfWork
import { PrismaUnitOfWork } from "@woltz/rich-domain-prisma";
describe("CreateUserUseCase", () => {
let mockUow: jest.Mocked<PrismaUnitOfWork>;
let mockUserRepository: jest.Mocked<UserRepository>;
beforeEach(() => {
mockUow = {
transaction: jest.fn((work) => work()),
getCurrentContext: jest.fn(),
isInTransaction: jest.fn(),
} as any;
mockUserRepository = {
save: jest.fn(),
findByEmail: jest.fn(),
} as any;
});
it("should create user", async () => {
mockUserRepository.findByEmail.mockResolvedValue(null);
const useCase = new CreateUserUseCase(mockUserRepository, mockUow);
const user = await useCase.execute({
name: "John",
email: "[email protected]",
});
expect(user.name).toBe("John");
expect(mockUserRepository.save).toHaveBeenCalled();
});
});License
MIT
