DEV Community

Cover image for Stop Showing Your Database Schema to the World: A Better Way to Handle Errors in Express + Prisma
Rosa
Rosa

Posted on

Stop Showing Your Database Schema to the World: A Better Way to Handle Errors in Express + Prisma

So I was working on this side project, and I realized something embarrassing: my API was basically broadcasting my entire database structure to anyone who sent a malformed request.

You know that moment when you're testing your endpoint and you get back something like this?

{
  "error": "PrismaClientKnownRequestError: \nInvalid `prisma.user.create()` invocation:\n\n{\n  data: {\n    email: \"user@example.com\",\n           ~~~~~~~~~~~~~~~~~~\n    name: \"User\"\n  }\n}\n\nUnique constraint failed on the fields: (`email`)"
}
Enter fullscreen mode Exit fullscreen mode

That's not just ugly—it's a security nightmare. It is basically handing over your database blueprint to anyone who hits your endpoints.

What I really wanted was something clean like this:

{
  "status": "fail",
  "message": "Duplicate value found for email. Please use a different value."
}
Enter fullscreen mode Exit fullscreen mode

Much better, right?

The Usual Tutorial Problem

Here's the thing about most Express tutorials: they show you the happy path. But real apps? They're chaos. Users send garbage data, your database goes down, network requests fail.
Without proper error handling, you're basically coding blindfolded.

My Solution: Error Translation Layer

Instead of trying to catch every possible error, I built a translation layer. Think of it as a bouncer for your errors—it takes the messy backend stuff and turns it into something your frontend can actually use.

Let me show you how I did it.

Setting Up the Project

First, we need a project that can actually break in interesting ways. I'm using Express with Prisma and SQLite because they generate the kind of detailed errors that make this demonstration worthwhile.

mkdir global-error-handling
cd global-error-handling
npm init -y
Enter fullscreen mode Exit fullscreen mode

Install the essentials:

# Core dependencies
npm install express cors helmet dotenv @prisma/client

# Development tools
npm install -D typescript @types/express @types/node nodemon ts-node prisma
Enter fullscreen mode Exit fullscreen mode

TypeScript Configuration

Create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["ES2020"],
    "module": "commonjs",
    "rootDir": "./src",
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "outDir": "./dist",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}
Enter fullscreen mode Exit fullscreen mode

Package.json Scripts

Update your package.json scripts section:

{
  "scripts": {
    "dev": "nodemon src/server.ts",
    "build": "tsc",
    "start": "node dist/server.js",
    "db:generate": "prisma generate",
    "db:push": "prisma db push",
    "db:studio": "prisma studio"
  }
}
Enter fullscreen mode Exit fullscreen mode

Database Setup with Prisma

Initialize Prisma:

npx prisma init --datasource-provider sqlite
Enter fullscreen mode Exit fullscreen mode

Create your database schema in prisma/schema.prisma:

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = "file:./dev.db"
}

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String?
  posts     Post[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String?
  published Boolean  @default(false)
  author    User     @relation(fields: [authorId], references: [id], onDelete: Cascade)
  authorId  Int
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}
Enter fullscreen mode Exit fullscreen mode

Generate the Prisma client and push the schema:

npm run db:generate
npm run db:push
Enter fullscreen mode Exit fullscreen mode

Building the Error Handling System

Now, let's build our error handling system. Create the src directory and start with our utility classes.

Creating a Custom Error Class

// src/utils/appError.ts
class AppError extends Error {
  statusCode: number;
  status: "fail" | "error";
  isOperational: boolean;

  constructor(message: string, statusCode: number) {
    super(message);
    this.statusCode = statusCode;
    this.status = `${statusCode}`.startsWith("4")
      ? "fail"
      : "error";
    this.isOperational = true;
    this.message = message;

    Error.captureStackTrace(this, this.constructor);
  }
}

export default AppError;
Enter fullscreen mode Exit fullscreen mode

Eliminating Try-Catch Hell

// src/utils/catchAsync.ts
import { Request, Response, NextFunction } from 'express';

const catchAsync = (
  fn: (req: Request, res: Response, next: NextFunction) => Promise<unknown>,
) => {
  return (req: Request, res: Response, next: NextFunction) => {
    fn(req, res, next).catch((err) => next(err));
  };
};

export default catchAsync;
Enter fullscreen mode Exit fullscreen mode

The Main Event: Global Error Handler

This is where the magic happens. Express lets you define a global error handler that catches everything:

// src/middleware/errorHandler.ts
import { NextFunction, Request, Response } from "express";
import {
  PrismaClientKnownRequestError,
  PrismaClientValidationError,
  PrismaClientInitializationError,
  PrismaClientRustPanicError,
} from "@prisma/client/runtime/library";
import AppError from "../utils/appError";

interface ErrorWithCode extends Error {
  code?: string;
  statusCode?: number;
  status?: string;
  isOperational?: boolean;
}

const handlePrismaValidationError = (
  err: PrismaClientValidationError
): AppError => {
  return new AppError(
    "Invalid input data. Please check your request.",
    400
  );
};

const handlePrismaKnownError = (
  err: PrismaClientKnownRequestError
): AppError => {
  const errorMap: Record<string, () => AppError> = {
    P2002: () => {
      const field =
        (err.meta?.target as string[])?.join(", ") ||
        "field";
      return new AppError(
        `Duplicate value found for ${field}. Please use a different value.`,
        409
      );
    },
    P2003: () => {
      const field =
        (err.meta?.field_name as string) ||
        (err.meta?.target as string) ||
        "unknown field";
      return new AppError(
        `Invalid reference for field: ${field}`,
        400
      );
    },
    P2025: () => new AppError("Record not found.", 404),
    P2021: () =>
      new AppError("The table does not exist.", 500),
    P2022: () =>
      new AppError("The column does not exist.", 500),
  };

  return (
    errorMap[err.code]?.() ||
    new AppError("Database error occurred.", 500)
  );
};

const handlePrismaInitializationError = (
  err: PrismaClientInitializationError
): AppError => {
  return new AppError(
    "Database connection failed. Please try again later.",
    500
  );
};

const handlePrismaRustPanicError = (
  err: PrismaClientRustPanicError
): AppError => {
  return new AppError(
    "Critical database error occurred. Please try again.",
    500
  );
};

// Development error response
const sendErrorDev = (
  err: ErrorWithCode,
  res: Response
): void => {
  res.status(err.statusCode || 500).json({
    status: err.status,
    message: err.message,
    error: err,
    stack: err.stack,
  });
};

// Production error response
const sendErrorProd = (
  err: ErrorWithCode,
  res: Response
): void => {
  if (err.isOperational) {
    res.status(err.statusCode || 500).json({
      status: err.status,
      message: err.message,
    });
  } else {
    res.status(500).json({
      status: "error",
      message: "Something went wrong!",
    });
  }
};

// Global error handling middleware
const globalErrorHandler = (
  err: ErrorWithCode,
  req: Request,
  res: Response,
  next: NextFunction
): void => {
  err.statusCode = err.statusCode || 500;
  err.status = err.status || "error";

  const isDev = process.env.NODE_ENV === "development";
  let error = err;

  if (err instanceof PrismaClientValidationError) {
    error = handlePrismaValidationError(err);
  } else if (err instanceof PrismaClientKnownRequestError) {
    error = handlePrismaKnownError(err);
  } else if (
    err instanceof PrismaClientInitializationError
  ) {
    error = handlePrismaInitializationError(err);
  } else if (err instanceof PrismaClientRustPanicError) {
    error = handlePrismaRustPanicError(err);
  }

  if (isDev) {
    sendErrorDev(error, res);
  } else {
    sendErrorProd(error, res);
  }
};

export default globalErrorHandler;
Enter fullscreen mode Exit fullscreen mode

The key insight here is the two-environment approach. During development, you want to see everything—stack traces, error objects, the full disaster. In production, you want clean, professional responses that don't leak implementation details.

Building Routes That Actually Break

Let's create some routes that demonstrate different error scenarios:

// src/routes/users.ts
import { Router, Request, Response } from "express";
import { PrismaClient } from "@prisma/client";

import AppError from "../utils/appError";
import catchAsync from "../utils/catchAsync";

const router = Router();
const prisma = new PrismaClient();

router.get(
  "/:id",
  catchAsync(async (req: Request, res: Response) => {
    const { id } = req.params;

    const user = await prisma.user.findUnique({
      where: { id: parseInt(id) },
      include: {
        posts: true,
      },
    });

    if (!user) {
      throw new AppError("User not found", 404);
    }

    res.status(200).json({
      status: "success",
      data: { user },
    });
  })
);

router.post(
  "/",
  catchAsync(async (req: Request, res: Response) => {
    const { email, name } = req.body;

    if (!email) {
      throw new AppError("Email is required", 400);
    }

    // This will trigger Prisma P2002 error if email already exists
    const user = await prisma.user.create({
      data: {
        email,
        name,
      },
    });

    res.status(201).json({
      status: "success",
      data: { user },
    });
  })
);

router.post(
  "/:id/posts",
  catchAsync(async (req: Request, res: Response) => {
    const { id } = req.params;
    const { title, content } = req.body;

    if (!title) {
      throw new AppError("Title is required", 400);
    }

    // This will trigger Prisma P2003 error if user doesn't exist
    const post = await prisma.post.create({
      data: {
        title,
        content,
        authorId: parseInt(id),
      },
    });

    res.status(201).json({
      status: "success",
      data: { post },
    });
  })
);

router.delete(
  "/:id",
  catchAsync(async (req: Request, res: Response) => {
    const { id } = req.params;

    // This will trigger Prisma P2025 error if user doesn't exist
    await prisma.user.delete({
      where: { id: parseInt(id) },
    });

    res.status(204).json({
      status: "success",
      data: null,
    });
  })
);

export default router;
Enter fullscreen mode Exit fullscreen mode

Putting It All Together

// src/app.ts
import express from "express";
import cors from "cors";
import helmet from "helmet";
import AppError from "./utils/appError";
import globalErrorHandler from "./middleware/errorHandler";
import userRoutes from "./routes/users";

const app = express();

// Basic middleware
app.use(helmet()); 
app.use(cors());
app.use(express.json({ limit: "10kb" })); 

// API routes
app.use("/api/users", userRoutes);

// Catch undefined routes
app.all("*", (req, res, next) => {
  next(
    new AppError(
      `Can't find ${req.originalUrl} on this server`,
      404
    )
  );
});

// Global error handler (must be last!)
app.use(globalErrorHandler);

export default app;

Enter fullscreen mode Exit fullscreen mode

Server Setup with Process Handling

// src/server.ts
import { PrismaClient } from "@prisma/client";
import dotenv from "dotenv";
import app from "./app";

dotenv.config();

const prisma = new PrismaClient();
const port = process.env.PORT || 3000;

/**
 * Handle uncaught exceptions
 * These are synchronous errors that occur outside of Express
 */
process.on("uncaughtException", (err: Error) => {
  console.log("💥 UNCAUGHT EXCEPTION! Shutting down...");
  console.log(err.name, err.message);
  process.exit(1);
});

/**
 * Start the server
 */
async function startServer() {
  try {
    // Connect to database
    await prisma.$connect();
    console.log("Database connected successfully");

    // Start Express server
    const server = app.listen(port, () => {
      console.log(`Server running on port ${port}`);
    });

    /**
     * Handle unhandled promise rejections
     * These are async errors that aren't caught anywhere
     */
    process.on("unhandledRejection", (err: Error) => {
      console.log(
        "💥 UNHANDLED REJECTION! Shutting down..."
      );
      console.log(err.name, err.message);

      server.close(() => {
        process.exit(1);
      });
    });

    /**
     * Graceful shutdown on SIGTERM
     */
    process.on("SIGTERM", async () => {
      console.log(
        "SIGTERM received. Shutting down gracefully..."
      );

      await prisma.$disconnect();
      server.close(() => {
        console.log("Process terminated");
      });
    });
  } catch (error) {
    console.error("Failed to start server:", error);
    await prisma.$disconnect();
    process.exit(1);
  }
}

startServer();
Enter fullscreen mode Exit fullscreen mode

Running the Application

  1. Create a .env file:
NODE_ENV=development
PORT=3000
Enter fullscreen mode Exit fullscreen mode
  1. Start the development server:
npm run dev
Enter fullscreen mode Exit fullscreen mode

Testing The System

To test our error handling system, create an api.http file in your project root. This file works with the REST Client extension in VS Code, making it easy to test your API endpoints.

Create api.http:

## 1. SUCCESS CASES (To Setup Test Data)

### Create first user (SUCCESS)
POST http://localhost:3000/api/users
Content-Type: application/json

{
  "email": "user1@example.com",
  "name": "user1"
}

## 2. PRISMA ERROR TESTING

### P2002 - Unique Constraint Violation (Duplicate Email)
### This will trigger the global error handler for duplicate emails
POST http://localhost:3000/api/users
Content-Type: application/json

{
  "email": "user1@example.com",
  "name": "Another user1"
}

### P2003 - Foreign Key Constraint Violation (Invalid User ID)
### This will trigger the global error handler for invalid foreign key
POST http://localhost:3000/api/users/999/posts
Content-Type: application/json

{
  "title": "Post with Invalid User",
  "content": "This should fail because user 999 doesn't exist"
}

### P2025 - Record Not Found (Delete Non-existent User)
### This will trigger the global error handler for record not found
DELETE http://localhost:3000/api/users/999

## 3. VALIDATION ERRORS 

### Missing required email field
POST http://localhost:3000/api/users
Content-Type: application/json

{
  "name": "User Without Email"
}

### Missing required title field for post
POST http://localhost:3000/api/users/1/posts
Content-Type: application/json

{
  "content": "Post without title"
}

### 4. MALFORMED REQUEST ERRORS

### Invalid JSON (will trigger Express JSON parser error)
POST http://localhost:3000/api/users
Content-Type: application/json

{
  "email": "test@example.com"
  "name": "Invalid JSON" // Missing comma
}
Enter fullscreen mode Exit fullscreen mode

To test both environments:

  1. Run tests with NODE_ENV=development (default)
  2. Change to NODE_ENV=production in your .env file and restart the server
  3. Run the same tests to see the difference in error responses

What This Gets You

With this setup, you get:

  • Consistent error responses across your entire API
  • User-friendly messages instead of database internals
  • Appropriate detail levels for dev vs production
  • Clean route handlers without try-catch hell
  • Proper HTTP status codes for different error types

The best part? Once you set this up, you barely think about it. Your routes stay clean, your users get helpful error messages, and you sleep better knowing your database schema isn't being broadcast to the internet.

Top comments (0)