Signal Notes
programming

Building a Small REST API with Prisma, Validation, and Clear Boundaries

A runnable API pattern that keeps HTTP, business rules, and Prisma persistence separate.

Published January 18, 2025Updated July 27, 20263 min read

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:

  1. The route translates HTTP.
  2. The service owns business rules.
  3. 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.

Sources