Replies: 10 comments 7 replies
|
In larger Prisma codebases, I would avoid making this a binary choice between "repository everywhere" and "Prisma directly everywhere". A pattern that has worked well for me is:
For example, instead of one repository per model with thin wrappers like: userRepository.findById(id)I would rather keep Prisma direct: await prisma.user.findUnique({ where: { id } })But once queries become domain-specific, extraction becomes useful: export function buildActiveCustomerWhere(input: CustomerFilters): Prisma.CustomerWhereInput {
return {
deletedAt: null,
...(input.search
? {
OR: [
{ name: { contains: input.search, mode: "insensitive" } },
{ email: { contains: input.search, mode: "insensitive" } },
],
}
: {}),
};
}Then the service can compose it: const where = buildActiveCustomerWhere(filters);
return prisma.customer.findMany({
where,
orderBy: { createdAt: "desc" },
take: limit,
skip: offset,
});For very large apps, I usually split by domain or module rather than by database table: src/
billing/
billing.service.ts
billing.queries.ts
billing.mappers.ts
users/
users.service.ts
users.queries.ts
shared/
prisma/
prisma.service.tsThe main thing I would avoid is creating a generic CRUD repository abstraction over Prisma too early. Prisma already gives you a typed query API, so wrapping every method often removes type power without adding much value. A good rule of thumb:
Example: async function createOrder(input: CreateOrderInput) {
return prisma.$transaction(async (tx) => {
const customer = await tx.customer.findUniqueOrThrow({
where: { id: input.customerId },
});
const order = await tx.order.create({
data: {
customerId: customer.id,
items: {
create: input.items,
},
},
});
return order;
});
}So my recommendation would be: start with Prisma in services, extract query builders and domain-specific repositories when duplication or complexity appears, and keep transaction boundaries visible. Did this resolve it? Feel free to mark as answer if so. |
|
Guys, how are you mapping Prisma query results to DTOs? I'm assuming you're using select rather than include. In my case, i need to create an Class for every repository select pattern ( every query ) and use object mapper to maintain mapping logic out of service classes. |
|
Hi everyone In complex business scenarios, how do you persist a complex domain object or aggregate root (MVC or DDD)? Persisting such objects often requires writes across multiple tables, preferably within the same transaction. My current approach is to encapsulate the write logic in a WriteRepository and explicitly pass the transaction context/manager through the call chain. It works, but it doesn't feel particularly elegant. I'd love to hear how others handle this kind of problem. |
|
This is a great architectural question, and there's no single "correct" answer — the right pattern depends on your team size, domain complexity, and how much abstraction overhead you're willing to maintain. Here's what teams actually use in production, with honest trade-offs for each. Pattern 1 — Prisma directly in the service layer (flat, no abstraction)The simplest approach. Services call // services/order.service.ts
export class OrderService {
constructor(private readonly prisma: PrismaClient) {}
async getOrdersForUser(userId: string, status?: OrderStatus) {
return this.prisma.order.findMany({
where: {
userId,
...(status ? { status } : {}),
},
include: { items: true },
orderBy: { createdAt: 'desc' },
});
}
}When this works:
When it breaks down:
Pattern 2 — Repository pattern (domain-scoped data access layer)A repository class per domain model encapsulates all queries for that entity. Services depend on the repository interface, not PrismaClient directly. // repositories/order.repository.ts
export interface IOrderRepository {
findByUser(userId: string, filters?: OrderFilters): Promise<Order[]>;
findById(id: string): Promise<Order | null>;
create(data: CreateOrderInput): Promise<Order>;
updateStatus(id: string, status: OrderStatus): Promise<Order>;
}
export class PrismaOrderRepository implements IOrderRepository {
constructor(private readonly prisma: PrismaClient) {}
async findByUser(userId: string, filters?: OrderFilters) {
return this.prisma.order.findMany({
where: {
userId,
...(filters?.status ? { status: filters.status } : {}),
...(filters?.from ? { createdAt: { gte: filters.from } } : {}),
},
include: { items: true },
orderBy: { createdAt: 'desc' },
});
}
async findById(id: string) {
return this.prisma.order.findUnique({
where: { id },
include: { items: { include: { product: true } } },
});
}
}
// services/order.service.ts
export class OrderService {
constructor(private readonly orderRepo: IOrderRepository) {}
async getUserOrders(userId: string, filters?: OrderFilters) {
return this.orderRepo.findByUser(userId, filters);
}
}When this works:
When it breaks down:
Pattern 3 — Composable query builders / filter functionsInstead of full repository classes, extract reusable // queries/order.queries.ts
export const orderFilters = {
forUser: (userId: string): Prisma.OrderWhereInput => ({
userId,
}),
withStatus: (status: OrderStatus): Prisma.OrderWhereInput => ({
status,
}),
active: (): Prisma.OrderWhereInput => ({
status: { notIn: ['CANCELLED', 'REFUNDED'] },
}),
placedAfter: (date: Date): Prisma.OrderWhereInput => ({
createdAt: { gte: date },
}),
};
export const orderIncludes = {
withItems: (): Prisma.OrderInclude => ({
items: { include: { product: true } },
}),
};
// services/order.service.ts
export class OrderService {
constructor(private readonly prisma: PrismaClient) {}
async getActiveOrdersForUser(userId: string) {
return this.prisma.order.findMany({
where: {
...orderFilters.forUser(userId),
...orderFilters.active(),
},
include: orderIncludes.withItems(),
orderBy: { createdAt: 'desc' },
});
}
}When this works:
When it breaks down:
Pattern 4 — Domain service layer with explicit read/write separation (CQRS-lite)Separate read concerns from write concerns explicitly. Read operations use thin query functions; write operations go through domain services that enforce business rules. // reads/order.reads.ts — thin, no business logic
export const getOrderWithItems = (prisma: PrismaClient, orderId: string) =>
prisma.order.findUnique({
where: { id: orderId },
include: { items: { include: { product: true } } },
});
export const getOrdersByUser = (
prisma: PrismaClient,
userId: string,
filters: OrderFilters
) =>
prisma.order.findMany({
where: { userId, ...buildOrderWhere(filters) },
orderBy: { createdAt: 'desc' },
});
// domain/order.domain.ts — business rules live here
export class OrderDomain {
constructor(private readonly prisma: PrismaClient) {}
async cancelOrder(orderId: string, requestedBy: string) {
const order = await getOrderWithItems(this.prisma, orderId);
if (!order) throw new NotFoundError('Order not found');
if (order.userId !== requestedBy) throw new ForbiddenError();
if (order.status === 'SHIPPED') throw new BusinessRuleError('Cannot cancel a shipped order');
return this.prisma.order.update({
where: { id: orderId },
data: { status: 'CANCELLED', cancelledAt: new Date() },
});
}
}When this works:
When it breaks down:
What teams actually do in production — the honest summaryBased on how large Prisma production codebases are typically structured:
The most pragmatic pattern for most production teams hitting scale is a hybrid: Prisma stays in the service layer for simple queries, composable query builder functions handle shared filter logic, and repository classes are introduced only for domain models complex enough to warrant the abstraction — not applied uniformly across all models. Practical recommendation for a large, multi-module codebaseStart with this structure and add abstraction only where pain appears: // prisma/client.ts
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;The singleton pattern above is important at scale — it prevents connection pool exhaustion from multiple PrismaClient instances being instantiated across modules (a common production issue). On testing — the practical approach at scaleRegardless of which pattern you choose, the two most viable testing strategies for Prisma at scale are: Option A — Test against a real database (recommended) Option B — Mock at the service boundary Hope this helps — if it answers your question, would you mind clicking "Mark as answer" so others searching for the same thing can find it quickly? |
|
A few patterns that hold up past 100 models, roughly from least to most structure: 1. One PrismaClient, but keep it behind boundaries. Single instantiated client (a singleton), but don't import it straight into route handlers. Everything goes through a layer. The real question isn't "repository vs service", it's "is Prisma an implementation detail behind a boundary, or is it your domain?" 2. Organise by feature or domain, not one big Each module owns its own queries, and cross module access goes through the other module's service rather than reaching into its tables directly. That's the thing that stops 100+ models turning into a big ball of mud. 3. Repositories for the gnarly queries, Prisma in the service for plain CRUD. Thin CRUD can just call Prisma in the service. Anything with a complex 4. Split the schema file. Prisma supports multi file schemas ( 5. Derive your types, don't hand write DTOs. One thing I'd avoid is a single generic Short version: one client, organise by business domain, named repositories for the complex queries, multi file schema, and derive your types from Prisma. If this was useful it'd be great if you could mark it as the answer. |
|
کد فلیتر روبیکا |
|
کد فلیتر روبیکا فوری |
|
I generally use a hybrid approach that keeps the codebase simple while allowing it to scale.
This approach has worked well because it avoids unnecessary abstractions early on while still providing reusable, testable query logic as the project grows. I'm also curious whether teams using Prisma 7 have adjusted this pattern or are still following a similar architecture. |
|
This is something I've wrestled with too, and the honest answer is there's no single correct pattern — but there are some clear anti-patterns that hurt at 100+ models. The core issue with Prisma at scale The instinct to reach for "repository pattern" comes from a world where the ORM was clunky and you wanted to hide it. Prisma's type-safe query API is actually pretty good, so wrapping every model in a thin repository that just proxies The pattern that actually holds up in large codebases is a pragmatic tiered approach: Tier 1 — Prisma directly in the service (the default) For queries that are local to one service and not reused, just call Prisma: // no ceremony needed
const user = await prisma.user.findUniqueOrThrow({ where: { id } });Tier 2 — Composable query builders for shared logic When the same filter/include logic appears in multiple places, extract it as a typed function rather than a class: // src/modules/orders/orders.queries.ts
import { Prisma } from '@prisma/client';
export function activeOrdersWhere(userId: string): Prisma.OrderWhereInput {
return {
userId,
status: { notIn: ['CANCELLED', 'REFUNDED'] },
deletedAt: null,
};
}
export const withOrderItems = (): Prisma.OrderInclude => ({
items: { include: { product: true } },
});Then services compose: const orders = await prisma.order.findMany({
where: activeOrdersWhere(userId),
include: withOrderItems(),
});Tier 3 — Repository/module for complex domain logic Repository classes only earn their place when you have complex multi-step queries, authorization scoping, or queries you need to unit-test in isolation. The bar should be real complexity, not just "every model gets a repo". Organize by feature, not by model This is probably the most important structural decision. At 100+ models, a flat Cross-module data access goes through the other module's service, not direct table access. That boundary stops things from becoming a big ball of mud as models accumulate. On transactions Transaction boundaries belong at the service/use-case layer, not inside repositories. The pattern that keeps things clear: async function createOrder(input: CreateOrderInput) {
return prisma.$transaction(async (tx) => {
const customer = await tx.customer.findUniqueOrThrow({ where: { id: input.customerId } });
const order = await tx.order.create({
data: { customerId: customer.id, items: { create: input.items } },
});
return order;
});
}If passing Multi-file schema If you haven't already, split the schema by domain too. Prisma supports multi-file schemas natively — one This directly maps to your module structure and stops merge conflicts in a 4000-line single file. The rule of thumb I'd apply
|
|
Addressing the Prisma 7 concern specifically — I just did this migration in a production NestJS codebase and the architecture question shifts meaningfully. The $use() → $extends() migration changes the pattern In Prisma 7, $use() middleware was removed. The replacement is $extends(), but it returns a new client instance rather than mutating the existing one. In NestJS, this breaks class PrismaService extends PrismaClient unless you bridge it: typescript } This matters for large codebases because all 9+ services that call prisma.invoice.findMany() keep working without changes — only PrismaService changes. On composable WhereInput objects in Prisma 7 Plain-object composition still works fine — Prisma.InvoiceWhereInput types are stable. The $extends() API is orthogonal to query building. You can still do: typescript The composable filter pattern is not going away with Prisma Next's chain API — the where object you build is still the input to whatever surface executes it. The pattern that scales at 100+ models with Prisma 7: PrismaService (singleton, $extends() bridge for cross-cutting concerns) |
Uh oh!
There was an error while loading. Please reload this page.
Question
In small projects, it's easy to keep Prisma queries inside services, but in larger applications with dozens of modules and hundreds of queries, how are teams organizing Prisma access?
Are you using repositories, domain services, query builders, or keeping Prisma directly in the service layer?
I'd love to hear real-world patterns from teams running Prisma in production.
All reactions