Building a Small REST API with Prisma, Validation, and Clear Boundaries
A runnable API pattern that keeps HTTP, business rules, and Prisma persistence separate.
Prisma gives a Node.js application a typed database client. It does not decide where validation, authorization, or business rules should live. A maintainable REST API still needs boundaries around the database.
This example uses three small layers:
- The route translates HTTP.
- The service owns business rules.
- The repository owns Prisma queries.
Define the persistence model
model Project { id String @id @default(uuid()) slug String @unique name String description String state String @default("draft") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }
After changing the schema in development, create a migration:
bunx prisma migrate dev --name add-project bunx prisma generate
Production should apply committed migrations with prisma migrate deploy. It should not invent a new migration during deployment.
Isolate Prisma in a repository
import type { PrismaClient, Project } from "@/generated/prisma/client"; export interface ProjectRepository { findBySlug(slug: string): Promise<Project | null>; create(input: { slug: string; name: string; description: string; }): Promise<Project>; } export class PrismaProjectRepository implements ProjectRepository { constructor(private readonly database: PrismaClient) {} findBySlug(slug: string) { return this.database.project.findUnique({ where: { slug } }); } create(input: { slug: string; name: string; description: string }) { return this.database.project.create({ data: input }); } }
The service depends on the interface:
export class ProjectService { constructor(private readonly projects: ProjectRepository) {} async create(input: { slug: string; name: string; description: string; }) { const existing = await this.projects.findBySlug(input.slug); if (existing) throw new Error("Project slug is already in use"); return this.projects.create(input); } }
A unit test can inject a fake repository without starting PostgreSQL. Integration tests can cover the Prisma implementation separately.
Validate at the HTTP boundary
import { z } from "zod"; const createProjectSchema = z.object({ slug: z.string().regex(/^[a-z0-9-]+$/).max(80), name: z.string().trim().min(3).max(120), description: z.string().trim().min(20).max(2000), }); export async function POST(request: Request) { const parsed = createProjectSchema.safeParse(await request.json()); if (!parsed.success) { return Response.json( { error: "Invalid project", fields: parsed.error.flatten().fieldErrors }, { status: 400 }, ); } try { const project = await projectService.create(parsed.data); return Response.json(project, { status: 201 }); } catch (error) { if (error instanceof Error && error.message.includes("already")) { return Response.json({ error: error.message }, { status: 409 }); } return Response.json({ error: "Unable to create project" }, { status: 500 }); } }
Authentication and authorization belong here too, before the service is allowed to mutate data.
Paginate intentionally
Prisma supports offset and cursor pagination. Offset pagination is convenient for shallow admin tables because users can jump to a page. Cursor pagination is more stable for long feeds.
const projects = await prisma.project.findMany({ where: { state: "published" }, orderBy: [{ createdAt: "desc" }, { id: "desc" }], cursor: cursor ? { id: cursor } : undefined, skip: cursor ? 1 : 0, take: 20, });
Always use deterministic ordering. An identical timestamp should not create an unstable page boundary.
Use transactions around one business event
If publishing an article also creates a revision and an audit entry, those writes describe one event. Prisma transactions keep them consistent.
The main goal is not more classes. It is a visible place for each kind of decision. HTTP errors stay in the route, publishing rules stay in the service, and query details stay in the repository.