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
Post a Comment