Building a Production Ready GraphQL API with Node.js and Prisma

A practical walkthrough of architecting, structuring, and implementing a URL shortener backend that handles authentication, authorization, pagination, and clean separation of concerns.

Why GraphQL

GraphQL gives clients exactly what they ask for and nothing more. For a URL shortener, this means a frontend can fetch only the fields it needs, whether it is a dashboard with all URL metadata or a simple list with just short codes and click counts. No overfetching, no underfetching, no versioning headaches.

Architecture Overview

The project follows a clean architecture pattern with four layers:

Client -> GraphQL Resolver -> Service -> Repository -> Database

Each layer has a single responsibility. Resolvers handle input validation and delegate to services. Services contain all business logic. Repositories own the database queries. This separation makes the code testable, maintainable, and easy to reason about.

Folder Structure

src/
  config/        Environment configuration
  context/       GraphQL context (auth)
  errors/        Custom error classes
  graphql/       Schema, resolvers, type definitions
  middleware/    Express middleware
  repositories/  Database queries
  routes/        Express routes (redirect)
  services/      Business logic
  utils/         JWT, bcrypt, Prisma client
  validators/    Input validation
  app.js         Apollo Server setup
  server.js      Entry point

Technology Decisions

Apollo Server

Apollo Server integrates directly with Express, provides error formatting hooks, and gives us a playground for development. The formatError hook is where we catch the distinction between expected business logic errors and unexpected system errors.

formatError: (formattedError, error) => {
  const originalError = error.originalError;
  if (originalError instanceof AppError) {
    return { message: originalError.message };
  }
  return { message: 'Internal server error' };
}

This guarantees that stack traces and implementation details never leak to the client.

Prisma ORM

Prisma provides type safety, auto generated queries, and a clean schema definition language. The schema is the single source of truth for the database.

model User {
  id        String   @id @default(cuid())
  email     String   @unique
  password  String
  urls      Url[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Url {
  id          String    @id @default(cuid())
  originalUrl String
  shortCode   String    @unique
  clicks      Int       @default(0)
  expiresAt   DateTime?
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt
  user        User?     @relation(fields: [userId], references: [id])
  userId      String?
}

A few design choices are worth calling out here. The userId field is nullable, so anonymous users can shorten URLs without an account. shortCode is unique and indexed for fast lookups. The relation between User and Url is optional on both sides, which keeps the schema flexible as usage patterns evolve.

Authentication Flow

JWTs are issued at register and login. The token payload contains only the userId. On every GraphQL request, the context function decodes the token and attaches the user payload to the context.

function createContext({ req }) {
  const authorization = req.headers.authorization || '';
  const token = authorization.replace('Bearer ', '');
  let user = null;
  if (token) {
    try {
      user = verifyToken(token);
    } catch {
      user = null;
    }
  }
  return { user };
}

This keeps resolvers clean. They simply check if (!user) and throw an UnauthorizedError. No token parsing logic lives in the resolver layer.

Error Handling Strategy

Custom error classes map directly to HTTP status codes.

class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
  }
}

class BadRequestError extends AppError { constructor(m) { super(m, 400); } }
class UnauthorizedError extends AppError { constructor(m) { super(m, 401); } }
class ForbiddenError extends AppError { constructor(m) { super(m, 403); } }
class NotFoundError extends AppError { constructor(m) { super(m, 404); } }
class ConflictError extends AppError { constructor(m) { super(m, 409); } }

Business logic errors are thrown in services and caught by Apollo's error formatting. System errors are logged and replaced with a generic message. This ensures clients never see database internals or stack traces.

Short Code Generation

The short code generator produces unique 6 to 8 character alphanumeric strings.

function generateShortCode() {
  const length = 6 + Math.floor(Math.random() * 3);
  const bytes = crypto.randomBytes(length);
  let code = '';
  for (let i = 0; i < length; i++) {
    code += CHARS[bytes[i] % CHARS.length];
  }
  return code;
}

Using crypto.randomBytes instead of Math.random provides cryptographically secure randomness. If the generated code collides with an existing one, the service retries until it finds a unique value.

Input Validation

Validation happens before any business logic executes. This keeps services focused on their domain and guarantees that malformed data never reaches the database.

function validateCreateUrlInput(originalUrl, shortCode, expiresAt) {
  validateUrl(originalUrl);
  validateShortCode(shortCode);
  validateExpiration(expiresAt);
}

Each validator throws a BadRequestError with a specific message. The resolver layer calls validation first, then delegates to the service.

Pagination

The myUrls query returns paginated results with a consistent response shape.

type UrlPage {
  items: [Url!]!
  totalItems: Int!
  totalPages: Int!
  currentPage: Int!
}

The resolver clamps the limit to a maximum of 100 to prevent abuse. The repository uses Prisma's skip and take for offset based pagination.

The Redirect Flow

The redirect endpoint is a plain Express route, not a GraphQL query. This is intentional. Browsers and crawlers need a simple HTTP redirect, not a GraphQL response.

GET /:shortCode

1. Look up shortCode in database
2. If expired or not found, return 404
3. Increment click count in a separate query
4. Return 301 redirect to original URL

Click counting uses Prisma's increment for atomic updates.

What This Project Demonstrates

This project exercises the core concepts you will encounter in real world GraphQL backends:

  • Schema design with queries and mutations
  • JWT authentication and context based authorization
  • Clean architecture with resolver, service, and repository layers
  • Centralized error handling with custom error classes
  • Input validation separated from business logic
  • Pagination with offset based cursors
  • Anonymous user support alongside authenticated users
  • URL expiration handling
  • Atomic click counting
  • Prisma ORM with a single shared client instance

Running the Project

npm install
npx prisma generate
npx prisma db push
npm run dev

The GraphQL playground is available at http://localhost:4000/graphql. The redirect endpoint responds at http://localhost:4000/:shortCode.

Final Thoughts

GraphQL shifts the paradigm from server defined responses to client defined queries. Combined with a clean layered architecture, it produces APIs that are flexible, maintainable, and a pleasure to work with. The patterns shown here, authentication flows, error handling, service layering, and validation, apply directly to any GraphQL API, not just URL shorteners.

The complete source code is available on GitHub for reference.

Comments

Popular Posts

Exploiting MS17-010 EternalBlue: SMB Flaw to SYSTEM Access

God Never Wrote a Book: A Nigerian Agnostic's Case

How I Patched CVE-2026-42945 on Monesize Nginx