Prisma is a schema-first ORM with first-class migrations and a generated, fully typed client. It pairs well with DaloyJS's contract-first routes: Zod validates the wire, Prisma validates the database. This guide targets Prisma ORM 7.
One request through Prisma
01clientHTTP requestGET /users/:id
02zodValidated inputparams.id is a uuid
03prismaTyped querystate.db.user.findUnique(...)
04responseTyped body200 UserSchema | 404
Zod validates the request before your handler runs, Prisma runs the typed query off state.db, and the result is checked against the response schema on the way out. Two validation layers guard the wire and the database.
1. Install
Pin the CLI, the client, and the driver adapter to the same major:
Prisma's current prisma-client generator writes the client to the configured output path. Connection URLs live in prisma.config.ts, and application code imports PrismaClient from the generated path instead of @prisma/client.
bash
pnpm prisma migrate dev --name initpnpm prisma generate
migrate dev is for development. In production, run prisma migrate deploy as a deploy step, never at app boot (see migrations).
3. Create a Prisma plugin
Instantiate one PrismaClient per process, decorate the app, and disconnect on shutdown.
src/db/prisma.tsts
import { PrismaPg } from "@prisma/adapter-pg";import { PrismaClient } from "../generated/prisma/client.ts";import type { App } from "@daloyjs/core";const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! });export const prisma = new PrismaClient({ adapter, log: process.env.NODE_ENV === "production" ? ["error"] : ["query", "error"],});// Call with the root app, not through app.register(): a decoration made// inside a registered plugin is visible only to that plugin's own routes.export async function prismaPlugin(app: App) { await prisma.$connect(); app.decorate("db", prisma); app.onClose(async () => { await prisma.$disconnect(); });}
onClose() runs on the Node.js, Bun, and Deno adapters only. On Lambda, Vercel, and Azure Functions keep the client at module scope with a small pool, and see Serverless and edge lifecycle.
4. Augment app state types
Add the declare module block to the same module that creates the client, not to a separate .d.ts file. Declaration files are exempt from type-checking when skipLibCheck is on (the scaffolded default), so a broken import inside a .d.ts fails silently and state.db degrades to any.
src/db/prisma.tsts
// same module as the plugin abovedeclare module "@daloyjs/core" { interface AppState { db: PrismaClient; }}
5. Wire the plugin and route
The route below demonstrates Prisma wiring, not ownership. If a record is user-owned or tenant-owned, do not query by URL ID alone. Constrain the same query with the trusted principal and follow the resource authorization guide.
src/server.tsts
import { z } from "zod";import { App, NotFoundError, secureHeaders, requestId } from "@daloyjs/core";import { serve } from "@daloyjs/core/node";import { prismaPlugin } from "./db/prisma.ts";const UserSchema = z.object({ id: z.uuid(), email: z.email(), name: z.string().nullable(),});const app = new App();// Register middleware before the routes: app.use() only applies to routes// added after it.app.use(requestId());app.use(secureHeaders());await prismaPlugin(app);app.get( "/users/:id", { operationId: "getUser", request: { params: z.object({ id: z.uuid() }) }, responses: { 200: { description: "Found", body: UserSchema }, 404: { description: "Not found" }, }, }, async ({ params, state }) => { const user = await state.db.user.findUnique({ where: { id: params.id } }); if (!user) throw new NotFoundError("User not found"); return { status: 200, body: user }; },);app.post( "/users", { operationId: "createUser", request: { body: z.object({ email: z.email(), name: z.string().optional() }) }, responses: { 201: { description: "Created", body: UserSchema } }, }, // Passing `body` straight to create() is safe only because z.object() // strips unknown keys (no mass assignment). async ({ body, state }) => ({ status: 201, body: await state.db.user.create({ data: body }), }),);await app.ready();serve(app, { port: 3000 });
Transactions
Use $transaction for atomic units of work. Throwing inside the callback rolls back. A successful return commits. This example assumes Order and Inventory models in your schema and a route that declares 201.
For Cloudflare Workers, set the generator runtime to workerd (alias cloudflare) and use a Prisma driver adapter (Neon, PlanetScale, D1, and others). The prisma-client generator also accepts moduleFormat (esm or cjs) and importFileExtension (for example ts, matching the .ts imports a scaffolded DaloyJS app uses).
A Worker cannot reuse a database connection across requests, so build the client per request in a hook, registered before the routes:
src/worker.tsts
import { env } from "cloudflare:workers";import { PrismaNeon } from "@prisma/adapter-neon";import { App } from "@daloyjs/core";import { toFetchHandler } from "@daloyjs/core/cloudflare";import { PrismaClient } from "./generated/prisma/client.ts";const app = new App();app.use({ preBody({ state }) { const adapter = new PrismaNeon({ connectionString: env.DATABASE_URL }); state.db = new PrismaClient({ adapter }); },});// ...register routes that read state.dbexport default toFetchHandler(app);
Operator injection: validate your filter shapes
Prisma always emits parameterized SQL, but the filter object you pass to where is interpreted by Prisma. If a field annotated as string arrives at runtime as an object like { "not": "" }, Prisma treats it as an operator and an attacker can bypass equality checks, the "NoSQL-injection-in-Prisma" pattern documented by Aikido. Daloy's contract-first routes neutralize this when you keep the request body typed with primitive Zod schemas (z.string(), z.number(), ...) instead of z.any(), z.unknown(), or a pass-through z.record(). The same applies to decoded pagination cursors. See Security, SQL injection for the full pattern and review-time rules.
Mapping errors to problem+json
Keep the title generic: it is always sent to the client, and Prisma's messages name tables and constraints. The full code table lives in the ORM overview.
ts
import { Prisma } from "../generated/prisma/client.ts";import { ConflictError, NotFoundError } from "@daloyjs/core";try { const user = await state.db.user.update({ where: { id: params.id }, data: body }); return { status: 200, body: user };} catch (err) { if (err instanceof Prisma.PrismaClientKnownRequestError) { if (err.code === "P2002") throw new ConflictError("Email already in use"); if (err.code === "P2025") throw new NotFoundError("User not found"); } throw err;}