Building a Bulletproof API: Express, TypeScript, Prisma 7 & Zod

In my previous post, I talked about moving away from "spaghetti code." Today, I want to walk through how to build a production-ready REST API using Express and TypeScript with complete CRUD operations.
We aren't just going to write everything in one server.ts file. Instead, we're going to use a Layered Architecture (Controller-Service-Repository pattern) to keep our business logic clean, testable, and fully type-safe.
The Stack
- Runtime: Node.js 20+ (with pnpm)
- Framework: Express.js
- Language: TypeScript (Strict mode)
- ORM: Prisma 7 (with type-safe database access)
- Validation: Zod (for runtime validation)
- Error Handling: Custom middleware
Project Structure
src/ ├── config/ │ └── database.ts # Prisma client singleton ├── controllers/ │ └── post.controller.ts # HTTP request handlers ├── services/ │ └── post.service.ts # Business logic layer ├── repositories/ │ └── post.repository.ts # Database access layer ├── schemas/ │ └── post.schema.ts # Zod validation schemas ├── routes/ │ └── post.routes.ts # API route definitions ├── middlewares/ │ ├── validate.middleware.ts │ └── error.middleware.ts ├── types/ │ └── index.ts # Shared TypeScript types ├── utils/ │ └── errors.ts # Custom error classes └── server.ts # Application entry point prisma/ ├── schema.prisma # Database schema └── migrations/ # Migration files prisma.config.ts # Prisma configuration (NEW in v7)
The Architecture
We will separate our code into four distinct layers:
- Routes: Define the endpoints (URLs)
- Controllers: Handle HTTP request/response and validation
- Services: Handle business logic and orchestration
- Repositories: Handle direct database access (Prisma operations)
This separation makes each layer independently testable and maintainable.
Step 1: Prisma Configuration (prisma.config.ts)
NEW in Prisma 7:
The database connection URL is now configured in prisma.config.ts instead of in the schema file.
// This file should be at the root of your project (same level as package.json)
import 'dotenv/config';
import { defineConfig, env } from 'prisma/config';
export default defineConfig({
schema: 'prisma/schema.prisma',
migrations: {
path: 'prisma/migrations',
},
datasource: {
url: env('DATABASE_URL'),
},
});
Prisma Schema (prisma/schema.prisma)
IMPORTANT: Do NOT include the URL field in the datasource block anymore. It's deprecated in Prisma 7.
generator client {
provider = "prisma-client-js"
output = "../src/generated/prisma/client"
}
datasource db {
provider = "postgresql"
// NO url field here anymore - it's in prisma.config.ts
}
model Post {
id String @id @default(uuid())
title String @db.VarChar(255)
content String
published Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([createdAt])
@@index([published])
}
Environment Variables (.env)
DATABASE_URL="postgresql://user:password@localhost:5432/mydb?schema=public"
Prisma Client with Driver Adapter (src/config/database.ts)
NEW in Prisma 7: You must use driver adapters for all databases.
import 'dotenv/config';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../generated/prisma/client';
const connectionString = process.env.DATABASE_URL!;
const adapter = new PrismaPg({
connectionString,
});
const prismaClientSingleton = () => {
return new PrismaClient({
adapter,
log: process.env.NODE_ENV === 'development'
? ['query', 'error', 'warn']
: ['error'],
});
};
declare global {
var prismaGlobal: undefined | ReturnType<typeof prismaClientSingleton>;
}
const prisma = globalThis.prismaGlobal ?? prismaClientSingleton();
if (process.env.NODE_ENV !== 'production') {
globalThis.prismaGlobal = prisma;
}
export default prisma;
Step 2: Validation Layer (Zod Schemas)
Post Schemas (src/schemas/post.schema.ts)
Instead of manually writing TypeScript interfaces, we define Zod schemas and infer the types.
import { z } from 'zod';
export const createPostSchema = z.object({
title: z.string().min(1, "Title is required").max(255, "Title too long"),
content: z.string().min(10, "Content must be at least 10 characters"),
published: z.boolean().optional().default(false),
});
export const updatePostSchema = z.object({
title: z.string().min(1).max(255).optional(),
content: z.string().min(10).optional(),
published: z.boolean().optional(),
}).refine(data => Object.keys(data).length > 0, {
message: "At least one field must be provided for update",
});
export const postIdSchema = z.object({
id: z.string().uuid("Invalid post ID format"),
});
export const queryPostsSchema = z.object({
published: z.enum(['true', 'false']).optional(),
limit: z.string().regex(/^\d+$/).transform(Number).optional(),
offset: z.string().regex(/^\d+$/).transform(Number).optional(),
});
// Inferred TypeScript types
export type CreatePostDto = z.infer<typeof createPostSchema>;
export type UpdatePostDto = z.infer<typeof updatePostSchema>;
export type PostIdParams = z.infer<typeof postIdSchema>;
export type QueryPostsDto = z.infer<typeof queryPostsSchema>;
Step 3: Error Handling
Custom Errors (src/utils/errors.ts)
export class AppError extends Error {
constructor(
public statusCode: number,
public message: string,
public isOperational = true
) {
super(message);
Object.setPrototypeOf(this, AppError.prototype);
}
}
export class NotFoundError extends AppError {
constructor(message: string = "Resource not found") {
super(404, message);
}
}
export class ValidationError extends AppError {
constructor(message: string = "Validation failed") {
super(400, message);
}
}
Error Middleware (src/middlewares/error.middleware.ts)
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../utils/errors';
import { Prisma } from '../generated/prisma/client';
export const errorHandler = (
error: Error,
req: Request,
res: Response,
next: NextFunction
) => {
// Handle known operational errors
if (error instanceof AppError) {
return res.status(error.statusCode).json({
status: 'error',
message: error.message,
});
}
// Handle Prisma errors
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === 'P2025') {
return res.status(404).json({
status: 'error',
message: 'Record not found',
});
}
}
// Log unexpected errors
console.error('Unexpected error:', error);
// Send generic error response
res.status(500).json({
status: 'error',
message: 'Internal server error',
});
};
Validation Middleware (src/middlewares/validate.middleware.ts)
import { Request, Response, NextFunction } from 'express';
import { AnyZodObject, ZodError } from 'zod';
export const validate = (schema: AnyZodObject) => {
return async (req: Request, res: Response, next: NextFunction) => {
try {
await schema.parseAsync({
body: req.body,
query: req.query,
params: req.params,
});
next();
} catch (error) {
if (error instanceof ZodError) {
return res.status(400).json({
status: 'error',
message: 'Validation failed',
errors: error.errors.map(err => ({
path: err.path.join('.'),
message: err.message,
})),
});
}
next(error);
}
};
};
Step 4: Repository Layer (Database Access)
Post Repository (src/repositories/post.repository.ts)
The repository handles all Prisma operations. This layer knows nothing about HTTP or business logic.
import prisma from '../config/database';
import { CreatePostDto, UpdatePostDto } from '../schemas/post.schema';
export const PostRepository = {
findAll: async (filters?: { published?: boolean; limit?: number; offset?: number }) => {
return await prisma.post.findMany({
where: filters?.published !== undefined ? { published: filters.published } : undefined,
take: filters?.limit,
skip: filters?.offset,
orderBy: { createdAt: 'desc' },
});
},
findById: async (id: string) => {
return await prisma.post.findUnique({
where: { id },
});
},
create: async (data: CreatePostDto) => {
return await prisma.post.create({
data,
});
},
update: async (id: string, data: UpdatePostDto) => {
return await prisma.post.update({
where: { id },
data,
});
},
delete: async (id: string) => {
return await prisma.post.delete({
where: { id },
});
},
count: async (filters?: { published?: boolean }) => {
return await prisma.post.count({
where: filters?.published !== undefined ? { published: filters.published } : undefined,
});
},
};
Step 5: Service Layer (Business Logic)
Post Service (src/services/post.service.ts)
The service layer orchestrates business logic. It doesn't know about HTTP, only about data and rules.
import { PostRepository } from '../repositories/post.repository';
import { CreatePostDto, UpdatePostDto } from '../schemas/post.schema';
import { NotFoundError } from '../utils/errors';
export const PostService = {
getAllPosts: async (filters?: { published?: boolean; limit?: number; offset?: number }) => {
const posts = await PostRepository.findAll(filters);
const total = await PostRepository.count(
filters?.published !== undefined ? { published: filters.published } : undefined
);
return {
data: posts,
meta: {
total,
limit: filters?.limit,
offset: filters?.offset,
},
};
},
getPostById: async (id: string) => {
const post = await PostRepository.findById(id);
if (!post) {
throw new NotFoundError(`Post with ID ${id} not found`);
}
return post;
},
createPost: async (data: CreatePostDto) => {
return await PostRepository.create(data);
},
updatePost: async (id: string, data: UpdatePostDto) => {
// Check if post exists
await PostService.getPostById(id);
return await PostRepository.update(id, data);
},
deletePost: async (id: string) => {
// Check if post exists
await PostService.getPostById(id);
await PostRepository.delete(id);
},
publishPost: async (id: string) => {
return await PostService.updatePost(id, { published: true });
},
unpublishPost: async (id: string) => {
return await PostService.updatePost(id, { published: false });
},
};
Step 6: Controller Layer (HTTP Handling)
Post Controller (src/controllers/post.controller.ts)
The controller is the "Traffic Cop." It receives requests, calls services, and sends responses.
import { Request, Response, NextFunction } from 'express';
import { PostService } from '../services/post.service';
import { CreatePostDto, UpdatePostDto } from '../schemas/post.schema';
export const PostController = {
getPosts: async (req: Request, res: Response, next: NextFunction) => {
try {
const filters = {
published: req.query.published === 'true' ? true : req.query.published === 'false' ? false : undefined,
limit: req.query.limit ? Number(req.query.limit) : undefined,
offset: req.query.offset ? Number(req.query.offset) : undefined,
};
const result = await PostService.getAllPosts(filters);
res.status(200).json({
status: 'success',
...result,
});
} catch (error) {
next(error);
}
},
getPost: async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const post = await PostService.getPostById(id);
res.status(200).json({
status: 'success',
data: post,
});
} catch (error) {
next(error);
}
},
createPost: async (req: Request, res: Response, next: NextFunction) => {
try {
const data: CreatePostDto = req.body;
const post = await PostService.createPost(data);
res.status(201).json({
status: 'success',
data: post,
});
} catch (error) {
next(error);
}
},
updatePost: async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const data: UpdatePostDto = req.body;
const post = await PostService.updatePost(id, data);
res.status(200).json({
status: 'success',
data: post,
});
} catch (error) {
next(error);
}
},
deletePost: async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
await PostService.deletePost(id);
res.status(204).send();
} catch (error) {
next(error);
}
},
publishPost: async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const post = await PostService.publishPost(id);
res.status(200).json({
status: 'success',
data: post,
});
} catch (error) {
next(error);
}
},
unpublishPost: async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const post = await PostService.unpublishPost(id);
res.status(200).json({
status: 'success',
data: post,
});
} catch (error) {
next(error);
}
},
};
Step 7: Routes
Post Routes (src/routes/post.routes.ts)
import { Router } from 'express';
import { PostController } from '../controllers/post.controller';
import { validate } from '../middlewares/validate.middleware';
import {
createPostSchema,
updatePostSchema,
postIdSchema,
queryPostsSchema
} from '../schemas/post.schema';
import { z } from 'zod';
const router = Router();
// GET /api/posts - Get all posts with optional filters
router.get(
'/',
validate(z.object({ query: queryPostsSchema })),
PostController.getPosts
);
// GET /api/posts/:id - Get single post
router.get(
'/:id',
validate(z.object({ params: postIdSchema })),
PostController.getPost
);
// POST /api/posts - Create new post
router.post(
'/',
validate(z.object({ body: createPostSchema })),
PostController.createPost
);
// PATCH /api/posts/:id - Update post
router.patch(
'/:id',
validate(z.object({
params: postIdSchema,
body: updatePostSchema
})),
PostController.updatePost
);
// DELETE /api/posts/:id - Delete post
router.delete(
'/:id',
validate(z.object({ params: postIdSchema })),
PostController.deletePost
);
// POST /api/posts/:id/publish - Publish post
router.post(
'/:id/publish',
validate(z.object({ params: postIdSchema })),
PostController.publishPost
);
// POST /api/posts/:id/unpublish - Unpublish post
router.post(
'/:id/unpublish',
validate(z.object({ params: postIdSchema })),
PostController.unpublishPost
);
export default router;
Step 8: Server Setup
Main Server (src/server.ts)
import express from 'express';
import postRoutes from './routes/post.routes';
import { errorHandler } from './middlewares/error.middleware';
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Routes
app.use('/api/posts', postRoutes);
// Health check
app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok' });
});
// Error handling (must be last)
app.use(errorHandler);
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Package.json
{
"name": "bulletproof-api",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:studio": "prisma studio",
"db:push": "prisma db push"
},
"dependencies": {
"@prisma/adapter-pg": "^7.0.0",
"@prisma/client": "^7.0.0",
"dotenv": "^16.4.0",
"express": "^4.18.2",
"pg": "^8.11.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.10.0",
"@types/pg": "^8.11.0",
"prisma": "^7.0.0",
"tsx": "^4.7.0",
"typescript": "^5.4.0"
}
}
TypeScript Configuration (tsconfig.json)
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"lib": ["ES2022"],
"moduleResolution": "bundler",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Setup Instructions
1. Install Dependencies
pnpm install
2. Initialize Prisma
npx prisma init
This creates prisma.config.ts automatically in Prisma 7.
3. Configure Environment Variables
Create a .env file:
DATABASE_URL="postgresql://user:password@localhost:5432/mydb?schema=public" NODE_ENV="development" PORT=3000
4. Run Database Migrations
pnpm db:migrate pnpm db:generate
5. Start Development Server
pnpm dev
Key Prisma 7 Changes
✅ What's New
- prisma.config.ts is mandatory - Database URL moved from schema to config file
- Driver adapters required - Must use @prisma/adapter-pg or similar
- Environment variables - Must import dotenv/config explicitly
- Custom output location - Recommended to generate client outside node_modules
❌ What's Deprecated
- url field in datasource block (schema.prisma)
- --schema and --url CLI flags
- Built-in database drivers (now use community drivers)
API Examples
# Get all posts
GET /api/posts
# Get published posts with pagination
GET /api/posts?published=true&limit=10&offset=0
# Get single post
GET /api/posts/123e4567-e89b-12d3-a456-426614174000
# Create post
POST /api/posts
{
"title": "My First Post",
"content": "This is the content of my first post",
"published": false
}
# Update post
PATCH /api/posts/123e4567-e89b-12d3-a456-426614174000
{
"title": "Updated Title"
}
# Publish post
POST /api/posts/123e4567-e89b-12d3-a456-426614174000/publish
# Delete post
DELETE /api/posts/123e4567-e89b-12d3-a456-426614174000
Why This Architecture Matters
Type Safety Everywhere
If I change the Prisma schema, TypeScript catches errors immediately across all layers.
Separation of Concerns
- Repository: Only knows Prisma
- Service: Only knows business logic
- Controller: Only knows HTTP
Testability
Each layer can be unit tested independently with mocks.
Scalability
Need to add caching? Add it to the repository. Need complex business rules? Add them in the service. The other layers don't care.
Maintainability
A new developer (or Future You) knows exactly where to find and fix bugs.
Share Your Thoughts
What are your thoughts?
Dharyl Carry S. Almora
Full Stack Web Developer specializing in Next.js, React, Node.js, Express, NestJS, PostgreSQL, TypeScript, Tailwind CSS, and Docker, with hands-on experience in workflow automation using n8n, and currently learning ASP.NET Core.
Resources