TypeScript: Express.js Database Integration with Prisma
typescriptexpressprismapostgresqlbackend
In the previous post, we built a complete task management API with Express.js — Zod validation, layered architecture, error handling, the works. One problem: all our data lives in a Map that disappears when the server restarts.
Time to fix that. We'll replace the in-memory store with PostgreSQL via Prisma ORM, and you'll see how the layered architecture we built pays off — only the repository layer changes. Everything else stays the same.
✅ Prisma ORM setup — schema, client generation, and VS Code integration
✅ Schema design — models, relations, enums, and field constraints
✅ Migrations — create, apply, and manage database changes safely
✅ Type-safe CRUD — replace in-memory store with real database queries
✅ Transactions — atomic operations that succeed or fail together
✅ Connection pooling — handle production traffic without exhausting connections
✅ Seeding — populate your database with initial or test data
✅ Testing — unit and integration tests for your database layer
1. Why Prisma?
In the ORM guide, we compared Prisma, TypeORM, and Drizzle in depth. Here's the short version of why Prisma fits this series:
Feature
Why It Matters
Auto-generated types
Your queries return typed objects — no manual type definitions needed
Schema-first design
Define your database in schema.prisma, generate everything from it
Migration system
SQL migrations generated from schema diffs, version-controlled
IntelliSense everywhere
Full autocomplete for queries, filters, includes — in VS Code
TypeScript-native
Built for TypeScript from day one, not bolted on
The real win: Prisma's generated types flow through your entire codebase. When you change a schema field, TypeScript catches every place that needs updating.
2. Project Setup
Start from where we left off in Post 3. Install Prisma and the PostgreSQL driver:
npm install @prisma/clientnpm install -D prisma
Initialize Prisma:
npx prisma init
This creates:
prisma/schema.prisma — your database schema
.env — with a DATABASE_URL placeholder
Update .env with your PostgreSQL connection string:
Notice how the layered architecture shines — we only touch task.repository.ts and add a Prisma client. Controllers, services, middleware, routes — all untouched.
3. Schema Design
Replace the generated prisma/schema.prisma with our task management schema:
// prisma/schema.prismagenerator client { provider = "prisma-client-js"}datasource db { provider = "postgresql" url = env("DATABASE_URL")}enum TaskStatus { TODO IN_PROGRESS DONE}enum TaskPriority { LOW MEDIUM HIGH}model Task { id String @id @default(uuid()) title String @db.VarChar(255) description String? @db.Text status TaskStatus @default(TODO) priority TaskPriority @default(MEDIUM) dueDate DateTime? @map("due_date") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") tags Tag[] @@index([status]) @@index([priority]) @@index([createdAt]) @@map("tasks")}model Tag { id String @id @default(uuid()) name String @unique @db.VarChar(50) tasks Task[] @@map("tags")}
Let's break down the design decisions:
Enums vs Strings
enum TaskStatus { TODO IN_PROGRESS DONE}
PostgreSQL native enums are enforced at the database level. No invalid values can sneak in, even from raw SQL or other applications hitting the same database. Prisma generates TypeScript enums that match, so you get compile-time safety too.
Field Mapping
dueDate DateTime? @map("due_date")
@map lets you use camelCase in TypeScript while keeping snake_case in the database — the convention each ecosystem expects. @@map("tasks") on the model does the same for table names.
We index the fields we filter and sort by. Without these, every query scans the entire table. With 100 rows it doesn't matter. With 100,000 rows, it matters a lot.
Relations
The Tag model creates a many-to-many relationship. Prisma handles the join table automatically — you don't need to create it yourself.
4. Migrations
Generate your first migration:
npx prisma migrate dev --name init
This does three things:
Creates prisma/migrations/20260310_init/migration.sql with the SQL
Applies the migration to your database
Regenerates the Prisma Client with updated types
Let's look at the generated SQL:
-- CreateEnumCREATE TYPE "TaskStatus" AS ENUM ('TODO', 'IN_PROGRESS', 'DONE');CREATE TYPE "TaskPriority" AS ENUM ('LOW', 'MEDIUM', 'HIGH');-- CreateTableCREATE TABLE "tasks" ( "id" TEXT NOT NULL, "title" VARCHAR(255) NOT NULL, "description" TEXT, "status" "TaskStatus" NOT NULL DEFAULT 'TODO', "priority" "TaskPriority" NOT NULL DEFAULT 'MEDIUM', "due_date" TIMESTAMP(3), "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP(3) NOT NULL, CONSTRAINT "tasks_pkey" PRIMARY KEY ("id"));-- CreateTableCREATE TABLE "tags" ( "id" TEXT NOT NULL, "name" VARCHAR(50) NOT NULL, CONSTRAINT "tags_pkey" PRIMARY KEY ("id"));-- CreateTable (join table)CREATE TABLE "_TagToTask" ( "A" TEXT NOT NULL, "B" TEXT NOT NULL, CONSTRAINT "_TagToTask_AB_pkey" PRIMARY KEY ("A","B"));-- CreateIndexCREATE INDEX "tasks_status_idx" ON "tasks"("status");CREATE INDEX "tasks_priority_idx" ON "tasks"("priority");CREATE INDEX "tasks_created_at_idx" ON "tasks"("created_at");CREATE UNIQUE INDEX "tags_name_key" ON "tags"("name");
Migration Workflow
The development and production migration workflows are different:
Development (prisma migrate dev): Generates migration SQL, applies it, regenerates client. Interactive — can reset the database if needed.
Production (prisma migrate deploy): Applies pending migrations only. Non-interactive, safe for CI/CD. Never generates new migrations.
Adding a New Field
Later, when you need to add a field:
model Task { // ... existing fields completedAt DateTime? @map("completed_at")}
npx prisma migrate dev --name add-completed-at
Prisma diffs your schema against the database and generates only the necessary ALTER statement:
ALTER TABLE "tasks" ADD COLUMN "completed_at" TIMESTAMP(3);
5. Prisma Client Singleton
Never create multiple Prisma Client instances. In development with hot reload, every file change creates a new connection pool. This exhausts database connections fast.
Why globalThis? In development, module-level variables get recreated on hot reload. globalThis persists across reloads, so we reuse the same Prisma instance.
Logging levels:
Development: Log queries (for debugging), errors, and warnings
Production: Log errors only (performance)
6. Update the Repository Layer
Here's the payoff. We replace the in-memory Map with Prisma queries. The interface stays identical — the service layer doesn't know or care that we switched to a real database.
Before (In-Memory)
// Old: src/repositories/task.repository.tsconst tasks = new Map<string, Task>();export class TaskRepository { async findMany(query: ListTasksQuery) { let results = Array.from(tasks.values()); // Manual filtering, sorting, pagination... return { tasks: results, total }; } async create(data: CreateTaskInput) { const task = { id: uuidv4(), ...data, createdAt: new Date(), updatedAt: new Date() }; tasks.set(task.id, task); return task; }}
This is exactly why we built the layered architecture. The database is an implementation detail that lives behind the repository interface.
Key Prisma Patterns
Parallel queries with Promise.all:
const [tasks, total] = await Promise.all([ prisma.task.findMany({ where, orderBy, skip, take }), prisma.task.count({ where }),]);
Two queries run concurrently instead of sequentially. Same result, half the time.
connectOrCreate for tags:
tags: { connectOrCreate: data.tags.map((tag) => ({ where: { name: tag }, create: { name: tag }, })),}
If the tag exists, link it. If not, create it and link it. One operation handles both cases.
Error code P2025:
if ((error as { code: string }).code === "P2025") { return null; // Record not found}
Prisma throws P2025 when update or delete targets a non-existent record. We catch it and return null so the service layer handles it with our domain errors.
7. Update the Zod Schemas
Our Zod schemas need minor updates to match the Prisma enums and add tag support:
Enum values are uppercase (TODO instead of todo) to match PostgreSQL enum conventions
Added dueDate field with z.coerce.date() — accepts both date strings and Date objects
Added tags array — strings that map to the Tag model
Added dueDate as a sortable field
8. Transactions
Some operations need to be atomic — they either all succeed or all fail. Prisma makes transactions straightforward.
Example: Bulk Task Assignment
Imagine assigning multiple tasks to "IN_PROGRESS" status with a new tag:
// src/repositories/task.repository.ts (add this method)async bulkUpdateStatus( ids: string[], status: string, tag?: string,): Promise<number> { const result = await prisma.$transaction(async (tx) => { // First, verify all tasks exist const existingCount = await tx.task.count({ where: { id: { in: ids } }, }); if (existingCount !== ids.length) { throw new Error( `Expected ${ids.length} tasks, found ${existingCount}`, ); } // Update all tasks const updated = await tx.task.updateMany({ where: { id: { in: ids } }, data: { status: status as "TODO" | "IN_PROGRESS" | "DONE", updatedAt: new Date(), }, }); // Add tag if provided if (tag) { const tagRecord = await tx.tag.upsert({ where: { name: tag }, update: {}, create: { name: tag }, }); for (const id of ids) { await tx.task.update({ where: { id }, data: { tags: { connect: { id: tagRecord.id } }, }, }); } } return updated.count; }); return result;}
How $transaction works:
Prisma opens a database transaction
All operations inside the callback use the tx client (not the global prisma)
If any operation throws, the entire transaction rolls back
If the callback completes, everything commits
When to use transactions:
Batch operations — update 50 tasks, all or nothing
Multi-table changes — create a task AND add tags atomically
Business rules requiring consistency — check + update must be atomic
Financial operations — debit one account, credit another
When you don't need them:
Single create, update, or delete — these are already atomic
Read-only queries — no data changes to roll back
Independent operations — tasks that don't depend on each other
9. Connection Pooling
Every Prisma Client maintains a connection pool. This is critical for production — opening a new database connection takes 20-50ms. Pooling reuses connections.
Default Behavior
Prisma uses a default pool size based on the formula:
pool_size = num_physical_cpus * 2 + 1
On a 4-core machine, that's 9 connections. For most applications, this is fine.
Configuring the Pool
For high-traffic applications, configure via the connection URL:
If you deploy to serverless (Vercel, AWS Lambda), each function instance creates its own pool. With hundreds of concurrent functions, you'll exhaust database connections.
Use Prisma Accelerate or an external pooler like PgBouncer:
# With PgBouncerDATABASE_URL="postgresql://user:pass@pgbouncer-host:6432/db?pgbouncer=true"
The pgbouncer=true flag tells Prisma to use compatible query modes.
Graceful Shutdown
Always disconnect on shutdown to release connections:
# Create test databasecreatedb taskdb_test# Set test DATABASE_URLexport DATABASE_URL="postgresql://postgres:password@localhost:5432/taskdb_test?schema=public"# Apply migrations to test databasenpx prisma migrate deploy# Run testsnpm test
Best practice: Use a separate database for tests. Never run tests against your development database — deleteMany() in setup will wipe your data.
12. Error Handling for Database Operations
Prisma throws specific error codes that map to meaningful HTTP responses. Add a Prisma error handler to your middleware:
// src/middleware/error-handler.ts (updated)import type { Request, Response, NextFunction } from "express";import { Prisma } from "@prisma/client";import { AppError } from "@/utils/errors.js";export function errorHandler( err: Error, _req: Request, res: Response, _next: NextFunction,) { // Handle Prisma errors if (err instanceof Prisma.PrismaClientKnownRequestError) { switch (err.code) { case "P2002": { const target = (err.meta?.target as string[])?.join(", "); res.status(409).json({ error: { code: "CONFLICT", message: `Unique constraint violation on: ${target}`, }, }); return; } case "P2025": res.status(404).json({ error: { code: "NOT_FOUND", message: "Record not found", }, }); return; case "P2003": res.status(400).json({ error: { code: "FOREIGN_KEY_VIOLATION", message: "Referenced record does not exist", }, }); return; } } // Handle our custom errors if (err instanceof AppError) { res.status(err.statusCode).json({ error: { code: err.code ?? "APP_ERROR", message: err.message, }, }); return; } // Unknown errors console.error("Unhandled error:", err); res.status(500).json({ error: { code: "INTERNAL_ERROR", message: process.env.NODE_ENV === "production" ? "An unexpected error occurred" : err.message, }, });}
Common Prisma error codes:
Code
Meaning
HTTP Status
P2002
Unique constraint violation
409 Conflict
P2025
Record not found
404 Not Found
P2003
Foreign key constraint failure
400 Bad Request
P2014
Required relation violation
400 Bad Request
13. Testing the Complete API
Start the server and test with curl:
npm run dev
Create a Task with Tags
curl -s -X POST http://localhost:3000/api/v1/tasks \ -H "Content-Type: application/json" \ -d '{ "title": "Integrate Prisma ORM", "description": "Replace in-memory store with PostgreSQL", "priority": "HIGH", "tags": ["backend", "database"] }' | jq