Data
Prisma on the startup context
One PrismaClient for the app's lifetime, created at startup and closed on shutdown — with Prisma's error codes translated to plain answers at the service boundary.
On this page
Install#
The generated client is a runtime dependency; the prisma CLI is a dev tool. The schema lives in prisma/schema.prisma, and the model maps to the same notes table the other recipes use.
bun add @prisma/client zod @rhythmjs/middleware
bun add -d prisma// prisma/schema.prisma
model Note {
id String @id @default(uuid())
title String
content String @default("")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("notes")
}The database handle#
PrismaClient manages its own pool, so the handle is short: construct it at startup and expose $disconnect() as close(). Passing datasourceUrl keeps the runtime independent of the .env file the CLI reads.
// src/database.ts
import { PrismaClient } from "@prisma/client";
const databaseUrl = process.env.DATABASE_URL ?? "postgres://postgres:postgres@localhost:5432/notes_prisma";
export interface Database {
prisma: PrismaClient;
close(): Promise<void>;
}
export function createDatabase(): Database {
const prisma = new PrismaClient({ datasourceUrl: databaseUrl });
return { prisma, close: () => prisma.$disconnect() };
}Not-found at the boundary#
Prisma throws P2025 when update or delete misses. The service catches exactly that code and answers with null / false, so the controller decides the HTTP shape — it throws HttpError(404) and the filter() boundary renders it. Everything else propagates and becomes a 500.
// src/notes/notes.service.ts
function isRecordNotFound(error: unknown): boolean {
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025";
}
export function createNotesService(prisma: PrismaClient) {
return {
list: () => prisma.note.findMany({ orderBy: { createdAt: "desc" } }),
get: (id: string) => prisma.note.findUnique({ where: { id } }),
create: (input: CreateNoteInput) => prisma.note.create({ data: input }),
async update(id: string, patch: UpdateNoteInput) {
try {
return await prisma.note.update({ where: { id }, data: patch });
} catch (error) {
if (isRecordNotFound(error)) return null;
throw error;
}
},
async remove(id: string) {
try {
await prisma.note.delete({ where: { id } });
return true;
} catch (error) {
if (isRecordNotFound(error)) return false;
throw error;
}
},
};
}Wiring the module#
Identical to every other recipe — only the context key changes. The app module declares prisma in its startup shape, src/main.ts assigns appModule.context.prisma = database.prisma before serving and calls database.close() on shutdown, and the notes module derives the service from it. The controller validates bodies with zod through validate("body", …) and checks responses with intercept(…).
// src/app.module.ts
export const appModule = new Rhythm<RhythmHttpContext, { prisma: PrismaClient }>({ name: "app", type: "module" })
.use(filter())
.register(notesModule)
.use(() => {
throw new HttpError(404, "Route not found");
});// src/notes/notes.module.ts
export const notesModule = new Rhythm<NotesModuleInput>({ name: "notes", type: "module" })
.use(derive(({ prisma }: NotesModuleInput) => ({ notesService: createNotesService(prisma) })))
.use(notesController.middleware());Migrations#
Checked-in SQL lives under prisma/migrations/. prisma migrate deploy applies pending migrations and nothing else — it does not regenerate the client, so generation is its own step. Author new migrations with prisma migrate dev against a development database.
bun run db:generate # prisma generate
bun run db:migrate # prisma migrate deploy
bun run db:migrate:dev # prisma migrate dev — author a new migrationWhere to next#
The shared pattern is introduced in the overview; the first concrete recipe is Drizzle. MikroORM adds a unit of work per request; MongoDB drops the ORM entirely. The full app is examples/prisma-postgres.