Design and Build an ATS API

Build a REST API for an Applicant Tracking System with Hono.js.

Introduction

30 Second Summary

When you apply for a job online, your application enters a system that tracks it through stages like screening, interviews, and offers. These systems power every modern hiring team, and building one teaches you how to model real-world workflows as code.

In this project, you will design and build the backend API for a mini Applicant Tracking System that manages job postings, candidate profiles, and applications flowing through a hiring pipeline.

What You'll Build

A REST API built with Hono.js that manages job postings, candidates, and applications with validated pipeline stage transitions and auto-generated OpenAPI documentation.

By the end of this project, you'll have:

  • A PostgreSQL database with a normalized schema for jobs, candidates, and applications connected by foreign keys.
  • A Hono.js REST API with full CRUD endpoints and Zod-based input validation on every route.
  • Auto-generated OpenAPI 3.0 documentation served through a Scalar UI directly from your running server.
  • Secret Mission: Add query filtering and pagination to the applications endpoint with proper OpenAPI documentation.

Are there any prerequisites?

You should be comfortable with Node.js and TypeScript, and have a basic understanding of REST APIs and SQL.

You will also need Docker installed on your machine to run PostgreSQL locally.

Before We Start

Before We Start...

Set Up the Project

Every API needs a solid foundation before you write any business logic. Your ATS API will use three core tools working together: Hono.js as the web framework, PostgreSQL as the database, and Drizzle ORM as the bridge between them.

In this step, you will scaffold the project, spin up a database in a Docker container, and wire everything together so the dev server starts cleanly.

In this step, get ready to:
  • Scaffold a Hono.js project and install all dependencies.
  • Start a PostgreSQL database with Docker.
  • Configure Drizzle ORM and verify the dev server starts.
Scaffold the Hono.js project and install dependencies

Hono.js provides a scaffold tool that generates a working project structure in one command. You will use the nodejs template, which configures the project to run on Node.js with TypeScript.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your Hono.js project by running:
npm create hono@latest ats-api

What will this command ask?

The scaffold tool is interactive. It will ask you which template to use.

  • When asked Which template do you want to use?, select nodejs.
  • If asked whether to install dependencies, select yes.
  • If asked which package manager, select npm.
  • Move into your new project folder by running:
cd ats-api
  • Confirm the project was created by running:
ls

You should see files like package.json, tsconfig.json, and a src folder in the output.

Don't see these files?

Make sure you selected the nodejs template during the scaffold prompt. If you accidentally chose a different template, delete the folder and run the scaffold command again.

help me troubleshoot why my Hono.js scaffold did not create the expected files

  • Install the additional production dependencies by running:
npm install drizzle-orm pg @hono/zod-openapi @scalar/hono-api-reference dotenv
  • Install the additional dev dependencies by running:
npm install -D drizzle-kit @types/pg tsx

What are these packages for?

  • drizzle-orm and pg connect your app to PostgreSQL using a type-safe query builder.
  • @hono/zod-openapi adds schema validation and auto-generates API documentation from your route definitions.
  • @scalar/hono-api-reference serves a visual UI for your OpenAPI docs.
  • drizzle-kit provides CLI tools for pushing schema changes to the database.
  • tsx runs TypeScript files directly without a separate compile step.

You should see a success message with no errors after each install command.

Seeing install errors?

Check that you are inside the ats-api folder (not the Desktop) by running pwd in your terminal. The path should end with /ats-api.

help me fix npm install errors in my Hono.js project

Now update your package.json scripts to include database commands and use tsx for the dev server.

  • Open package.json in your code editor.
  • Replace the entire "scripts" section with the following:
"scripts": {
  "dev": "tsx watch src/index.ts",
  "build": "tsc",
  "db:push": "drizzle-kit push",
  "db:studio": "drizzle-kit studio"
}

What do these scripts do?

  • dev starts the server and auto-restarts when you save changes.
  • build compiles TypeScript to JavaScript for production.
  • db:push applies your Drizzle schema to the database.
  • db:studio opens a visual database browser.
  • Also verify that your tsconfig.json matches the following content. Replace its contents if it differs:
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "skipLibCheck": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}
  • Save both package.json and tsconfig.json.

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the complete package.json at this point:

{
  "name": "ats-api",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc",
    "db:push": "drizzle-kit push",
    "db:studio": "drizzle-kit studio"
  },
  "dependencies": {
    "@hono/node-server": "^2.0.5",
    "@hono/zod-openapi": "^0.18.0",
    "@scalar/hono-api-reference": "^0.5.0",
    "dotenv": "^16.4.7",
    "drizzle-orm": "^0.45.2",
    "hono": "^4.12.25",
    "pg": "^8.13.0"
  },
  "devDependencies": {
    "@types/pg": "^8.11.0",
    "drizzle-kit": "^0.31.10",
    "tsx": "^4.19.0",
    "typescript": "^5.7.0"
  }
}

And here is the complete tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "skipLibCheck": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}
Start PostgreSQL with Docker

Your API needs a database to store jobs, candidates, and applications. Instead of installing PostgreSQL directly on your machine, you will run it inside a Docker container.

  • Start a PostgreSQL container by running:
docker run --name ats-postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=ats -p 5432:5432 -d postgres

What does this command do?

  • --name ats-postgres gives the container a friendly name so you can find it later.
  • -e POSTGRES_PASSWORD=postgres sets the database superuser password.
  • -e POSTGRES_DB=ats creates a database called ats on first startup.
  • -p 5432:5432 maps the container's port to your machine so your app can connect.
  • -d postgres runs the official PostgreSQL image in the background.
  • Verify the container is running by executing:
docker ps

You should see a row with ats-postgres in the output and a status of Up.

Container not showing up?

Make sure Docker Desktop is running. If you see a message about port 5432 already in use, you may have another PostgreSQL instance running. Stop it first or use a different host port.

help me debug why my Docker PostgreSQL container is not starting

Now create the .env file that tells your app how to connect to this database.

  • Create a new file called .env in the root of your ats-api folder.
  • Add the following content to .env:
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/ats

What does this connection string mean?

The format is postgresql://user:password@host:port/database. This matches the credentials you set in the Docker command: user postgres, password postgres, on localhost:5432, connecting to the ats database.

  • Save .env.
Configure Drizzle and verify the dev server

Drizzle ORM needs a configuration file that tells it where your schema lives and how to connect to the database.

  • Create a new file called drizzle.config.ts in the root of your ats-api folder.
  • Add the following code to drizzle.config.ts:
import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';

export default defineConfig({
  out: './drizzle',
  schema: './src/db/schema.ts',
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
});

What does this configuration do?

  • import 'dotenv/config' loads your .env file so the database URL is available.
  • schema points to where you will define your tables in the next step.
  • dialect: 'postgresql' tells Drizzle which SQL flavor to generate.
  • dbCredentials uses the DATABASE_URL from your .env to connect.
  • Save drizzle.config.ts.
  • Start the dev server to verify everything compiles by running:
npm run dev

You should see a message like Server is running on http://localhost:3000 in your terminal.

Seeing a compile error?

If you see an error about missing files in src/, the scaffold's default src/index.ts should already exist from the scaffold step. Check that you are in the ats-api directory and the file is present.

help me fix a compile error when starting my Hono.js dev server

  • Press Ctrl+C to stop the dev server for now.

✔️ Awesome, I've got everything!

Great. Make sure you have saved drizzle.config.ts and .env before moving on.

ⓧ I'd like to double check the full code

Here is drizzle.config.ts:

import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';

export default defineConfig({
  out: './drizzle',
  schema: './src/db/schema.ts',
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
});

Here is .env:

DATABASE_URL=postgresql://postgres:postgres@localhost:5432/ats

Your foundation is solid. You have a running database, a configured ORM, and a dev server that starts without errors. Next up, you will design the data model that stores jobs, candidates, and applications.

Design and Implement the Data Model

Your project environment is ready with Hono.js, Drizzle ORM, and a running PostgreSQL database. Now it is time to design the data that powers your Applicant Tracking System.

Before writing any API code, you need a clear picture of the entities in your system and how they relate to each other. An ATS revolves around three core concepts: jobs that companies post, candidates who apply, and applications that connect the two as they move through a hiring pipeline.

In this step, get ready to:
  • Design an Entity-Relationship Diagram documenting your data model.
  • Implement the schema in TypeScript using Drizzle ORM.
  • Push the schema to your PostgreSQL database and verify the tables exist.
Design the Entity-Relationship Diagram

An Entity-Relationship Diagram maps out the tables in your database and shows how they connect. Writing it down before coding prevents rework later.

  • Create a docs folder in your ats-api project by running this command:
mkdir docs
  • Create a new file called erd.md inside the docs folder and add the following content:
# ATS Entity-Relationship Diagram

## Entities

### jobs
| Column     | Type         | Constraints          |
|------------|--------------|----------------------|
| id         | serial       | PRIMARY KEY          |
| title      | varchar(256) | NOT NULL             |
| department | varchar(128) | NOT NULL             |
| description| text         | nullable             |
| status     | varchar(32)  | NOT NULL, default 'open' |
| created_at | timestamp    | NOT NULL, default now() |

### candidates
| Column     | Type         | Constraints          |
|------------|--------------|----------------------|
| id         | serial       | PRIMARY KEY          |
| name       | varchar(256) | NOT NULL             |
| email      | varchar(256) | NOT NULL, UNIQUE     |
| phone      | varchar(32)  | nullable             |
| created_at | timestamp    | NOT NULL, default now() |

### applications
| Column       | Type            | Constraints                        |
|--------------|-----------------|------------------------------------|
| id           | serial          | PRIMARY KEY                        |
| job_id       | integer         | NOT NULL, FK -> jobs.id            |
| candidate_id | integer         | NOT NULL, FK -> candidates.id      |
| stage        | pipeline_stage  | NOT NULL, default 'applied'        |
| applied_at   | timestamp       | NOT NULL, default now()            |
| updated_at   | timestamp       | NOT NULL, default now()            |

UNIQUE constraint on (job_id, candidate_id) - one application per candidate per job.

## pipeline_stage enum
Values: applied, screening, interview, offer, hired, rejected

## Relationships
- One job has many applications (1:N)
- One candidate has many applications (1:N)
- Each application belongs to exactly one job and one candidate

What does this diagram tell us?

The applications table is the heart of the system. It connects jobs to candidates and tracks where each application sits in the hiring pipeline via the pipeline_stage enum.

The unique constraint on (job_id, candidate_id) prevents a candidate from applying to the same job twice. This is a business rule enforced at the database level.

  • Save docs/erd.md.
  • Confirm the file exists by running:
ls docs

You should see erd.md in the output.

Implement the schema with Drizzle ORM

Now you will translate that diagram into TypeScript code. Drizzle ORM uses a schema-as-code approach where your table definitions are the single source of truth for both your database and your TypeScript types.

  • Create the src/db directory by running:
mkdir -p src/db
  • Create a new file at src/db/schema.ts and add the imports and pipeline stage enum:
import { pgTable, pgEnum, serial, text, varchar, timestamp, integer, unique } from 'drizzle-orm/pg-core';

// Define the hiring pipeline stages as a PostgreSQL enum
export const pipelineStageEnum = pgEnum('pipeline_stage', [
  'applied',
  'screening',
  'interview',
  'offer',
  'hired',
  'rejected',
]);

What does this code do?

The pgEnum function creates a PostgreSQL CREATE TYPE ... AS ENUM definition. This enforces valid pipeline stages at the database level, so no invalid values can sneak in.

The import line pulls in all the column-type helpers you need for the tables that follow.

  • Below the enum, add the jobs table definition:
// Jobs table - stores open positions at the company
export const jobs = pgTable('jobs', {
  id: serial('id').primaryKey(),
  title: varchar('title', { length: 256 }).notNull(),
  department: varchar('department', { length: 128 }).notNull(),
  description: text('description'),
  status: varchar('status', { length: 32 }).notNull().default('open'),
  createdAt: timestamp('created_at').notNull().defaultNow(),
});

What does this code do?

Each pgTable call defines a database table. The first argument is the SQL table name, and the second is an object mapping TypeScript property names to column definitions.

Notice that description has no .notNull() call. This makes it nullable, since job descriptions are optional. The status column defaults to 'open' so new jobs are automatically visible to candidates.

  • Below the jobs table, add the candidates table definition:
// Candidates table - stores applicant profiles
export const candidates = pgTable('candidates', {
  id: serial('id').primaryKey(),
  name: varchar('name', { length: 256 }).notNull(),
  email: varchar('email', { length: 256 }).notNull().unique(),
  phone: varchar('phone', { length: 32 }),
  createdAt: timestamp('created_at').notNull().defaultNow(),
});

What does this code do?

The .unique() constraint on email ensures no two candidates can register with the same email address. This prevents duplicate profiles in your system.

  • Below the candidates table, add the applications table definition:
// Applications table - connects candidates to jobs with pipeline tracking
export const applications = pgTable('applications', {
  id: serial('id').primaryKey(),
  jobId: integer('job_id').notNull().references(() => jobs.id),
  candidateId: integer('candidate_id').notNull().references(() => candidates.id),
  stage: pipelineStageEnum('stage').notNull().default('applied'),
  appliedAt: timestamp('applied_at').notNull().defaultNow(),
  updatedAt: timestamp('updated_at').notNull().defaultNow(),
}, (table) => ({
  uniqueApplication: unique().on(table.jobId, table.candidateId),
}));

What does this code do?

The .references(() => jobs.id) calls create foreign key constraints. If someone tries to insert an application pointing to a non-existent job or candidate, the database rejects it.

The third argument to pgTable defines a composite unique constraint on (jobId, candidateId). This enforces the business rule from your ERD: one application per candidate per job.

Why use a database-level enum for pipeline stages?

You could validate stages in application code only, but a database enum catches invalid values even if someone runs a manual SQL query. It also makes the allowed states explicit in the schema, which helps any developer reading the code understand the system.

  • Save src/db/schema.ts.

✔️ Awesome, I've got everything!

Great. Double check you have saved src/db/schema.ts before moving on.

ⓧ I'd like to double check the full code

Here is the complete src/db/schema.ts file:

import { pgTable, pgEnum, serial, text, varchar, timestamp, integer, unique } from 'drizzle-orm/pg-core';

export const pipelineStageEnum = pgEnum('pipeline_stage', [
  'applied',
  'screening',
  'interview',
  'offer',
  'hired',
  'rejected',
]);

export const jobs = pgTable('jobs', {
  id: serial('id').primaryKey(),
  title: varchar('title', { length: 256 }).notNull(),
  department: varchar('department', { length: 128 }).notNull(),
  description: text('description'),
  status: varchar('status', { length: 32 }).notNull().default('open'),
  createdAt: timestamp('created_at').notNull().defaultNow(),
});

export const candidates = pgTable('candidates', {
  id: serial('id').primaryKey(),
  name: varchar('name', { length: 256 }).notNull(),
  email: varchar('email', { length: 256 }).notNull().unique(),
  phone: varchar('phone', { length: 32 }),
  createdAt: timestamp('created_at').notNull().defaultNow(),
});

export const applications = pgTable('applications', {
  id: serial('id').primaryKey(),
  jobId: integer('job_id').notNull().references(() => jobs.id),
  candidateId: integer('candidate_id').notNull().references(() => candidates.id),
  stage: pipelineStageEnum('stage').notNull().default('applied'),
  appliedAt: timestamp('applied_at').notNull().defaultNow(),
  updatedAt: timestamp('updated_at').notNull().defaultNow(),
}, (table) => ({
  uniqueApplication: unique().on(table.jobId, table.candidateId),
}));

Now create the database connection module that the rest of your app will import.

  • Create a new file at src/db/index.ts and add the following code:
import 'dotenv/config';
import { drizzle } from 'drizzle-orm/node-postgres';

// Create the database connection using the URL from .env
export const db = drizzle(process.env.DATABASE_URL!);

What does this code do?

This file creates a single db instance that connects to PostgreSQL using the DATABASE_URL from your .env file. Every route handler will import this db object to run queries.

  • Save src/db/index.ts.

Seeing a TypeScript error?

If your editor shows an error on the drizzle-orm/node-postgres import, make sure drizzle-orm and pg are both installed. Run npm install again to confirm all dependencies are present.

help me fix a TypeScript import error in my Drizzle ORM db file

Push the schema to PostgreSQL

Your schema exists in TypeScript, but PostgreSQL does not know about it yet. The npx drizzle-kit push command reads your schema file, compares it to the current database state, and applies the necessary SQL to bring the database in sync.

  • Push your schema to the database by running:
npx drizzle-kit push

You should see SQL statements printed in your terminal showing the creation of the pipeline_stage enum and the jobs, candidates, and applications tables.

What does this command do?

Drizzle Kit reads your drizzle.config.ts to find the schema path and database URL. It then introspects the database, computes the difference, generates the SQL, and applies it directly. No migration files are created. This is ideal for rapid local development.

Connection refused error?

Make sure your Docker PostgreSQL container is still running. Check with docker ps and verify a container with port 5432 is listed. If it stopped, restart it with the same docker run command from Step 1.

help me fix a connection refused error when pushing my Drizzle schema to PostgreSQL

  • Verify the tables exist by launching Drizzle Studio:
npx drizzle-kit studio

Drizzle Studio opens at https://local.drizzle.studio. You should see three tables listed: jobs, candidates, and applications.

  • Press Ctrl+C in your terminal to stop Drizzle Studio once you have confirmed the tables are there.

Your database schema is live. Next up, you will build the API routes that let users create jobs, register candidates, and query the data through clean REST endpoints.

Build the Jobs and Candidates API

In the previous step, you designed and pushed your database schema. Your PostgreSQL tables are ready and waiting for data.

Now you need a way for the outside world to interact with those tables. In this step, you will build the REST API endpoints that let clients create jobs, register candidates, and query both. Each route will validate incoming data using Zod schemas and automatically generate OpenAPI documentation.

In this step, get ready to:
  • Create route files with Zod schemas and OpenAPI metadata for Jobs and Candidates.
  • Wire up the main app entry point with Scalar UI documentation.
  • Test the API endpoints with curl to verify validation and responses.
Create the Jobs route with schemas and endpoints

The @hono/zod-openapi package lets you define your API contract (schemas, routes, and handlers) in one file. Each Zod schema you create doubles as both runtime validation and OpenAPI documentation.

  • Create a new folder src/routes inside your ats-api project folder by running this command:
mkdir src/routes
  • Create a new file src/routes/jobs.ts and add the imports and Zod schemas:
import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi';
import { db } from '../db/index.js';
import { jobs } from '../db/schema.js';
import { eq } from 'drizzle-orm';

const app = new OpenAPIHono();

// Response schema describes the shape of a Job object in API responses
const JobSchema = z.object({
  id: z.number().openapi({ example: 1 }),
  title: z.string().openapi({ example: 'Senior Engineer' }),
  department: z.string().openapi({ example: 'Engineering' }),
  description: z.string().nullable().openapi({ example: 'Build awesome things' }),
  status: z.string().openapi({ example: 'open' }),
  createdAt: z.string().openapi({ example: '2026-06-17T00:00:00.000Z' }),
}).openapi('Job');

// Request schema validates incoming data when creating a job
const CreateJobSchema = z.object({
  title: z.string().min(1).openapi({ example: 'Senior Engineer' }),
  department: z.string().min(1).openapi({ example: 'Engineering' }),
  description: z.string().optional().openapi({ example: 'Build awesome things' }),
}).openapi('CreateJob');

What does this code do?

You import OpenAPIHono (an extended Hono class that supports OpenAPI), createRoute (defines the API contract for a route), and z (Zod, re-exported from the package with OpenAPI extensions).

The .openapi() calls on each field add example values and metadata that appear in the generated documentation. The .openapi('Job') call on the full object registers it as a named schema in the OpenAPI spec.

  • Below the schemas in the same file, add the route definitions and handlers:
const listJobsRoute = createRoute({
  method: 'get',
  path: '/api/jobs',
  responses: {
    200: {
      content: { 'application/json': { schema: z.array(JobSchema) } },
      description: 'List all jobs',
    },
  },
});

const createJobRoute = createRoute({
  method: 'post',
  path: '/api/jobs',
  request: { body: { content: { 'application/json': { schema: CreateJobSchema } } } },
  responses: {
    201: {
      content: { 'application/json': { schema: JobSchema } },
      description: 'Job created',
    },
  },
});

const getJobRoute = createRoute({
  method: 'get',
  path: '/api/jobs/{id}',
  request: {
    params: z.object({ id: z.string().openapi({ param: { name: 'id', in: 'path' }, example: '1' }) }),
  },
  responses: {
    200: {
      content: { 'application/json': { schema: JobSchema } },
      description: 'Get a job by ID',
    },
    404: { description: 'Job not found' },
  },
});

app.openapi(listJobsRoute, async (c) => {
  const allJobs = await db.select().from(jobs);
  return c.json(allJobs, 200);
});

app.openapi(createJobRoute, async (c) => {
  const body = c.req.valid('json');
  const [newJob] = await db.insert(jobs).values(body).returning();
  return c.json(newJob, 201);
});

app.openapi(getJobRoute, async (c) => {
  const { id } = c.req.valid('param');
  const [job] = await db.select().from(jobs).where(eq(jobs.id, Number(id)));
  if (!job) return c.json({ error: 'Job not found' }, 404);
  return c.json(job, 200);
});

export default app;

What does this code do?

Each createRoute call defines the HTTP method, path, request shape, and response shape for one endpoint. These definitions become both the runtime validator and the OpenAPI spec entry.

The app.openapi(route, handler) calls register each route with its handler. Inside the handler, c.req.valid('json') returns the validated request body, and c.req.valid('param') returns the validated path parameters. Both are fully type-safe.

  • Save src/routes/jobs.ts.

Seeing TypeScript errors?

Make sure you are importing z from @hono/zod-openapi and not from zod directly. The package re-exports Zod with OpenAPI extensions attached.

Also verify the .js extension on your import paths ('../db/index.js'). TypeScript with ESM modules requires these even though the source files are .ts.

Help me debug TypeScript errors in my Hono OpenAPI route file.

Create the Candidates route

The candidates route follows the exact same pattern as jobs. You define response and request schemas, declare routes with createRoute, then register handlers.

  • Create src/routes/candidates.ts with the following code:
import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi';
import { db } from '../db/index.js';
import { candidates } from '../db/schema.js';
import { eq } from 'drizzle-orm';

const app = new OpenAPIHono();

// Response schema for a Candidate object
const CandidateSchema = z.object({
  id: z.number().openapi({ example: 1 }),
  name: z.string().openapi({ example: 'Jane Doe' }),
  email: z.string().openapi({ example: 'jane@example.com' }),
  phone: z.string().nullable().openapi({ example: '+1234567890' }),
  createdAt: z.string().openapi({ example: '2026-06-17T00:00:00.000Z' }),
}).openapi('Candidate');

// Request schema validates incoming candidate data
const CreateCandidateSchema = z.object({
  name: z.string().min(1).openapi({ example: 'Jane Doe' }),
  email: z.string().email().openapi({ example: 'jane@example.com' }),
  phone: z.string().optional().openapi({ example: '+1234567890' }),
}).openapi('CreateCandidate');

const listCandidatesRoute = createRoute({
  method: 'get',
  path: '/api/candidates',
  responses: {
    200: {
      content: { 'application/json': { schema: z.array(CandidateSchema) } },
      description: 'List all candidates',
    },
  },
});

const createCandidateRoute = createRoute({
  method: 'post',
  path: '/api/candidates',
  request: { body: { content: { 'application/json': { schema: CreateCandidateSchema } } } },
  responses: {
    201: {
      content: { 'application/json': { schema: CandidateSchema } },
      description: 'Candidate created',
    },
  },
});

const getCandidateRoute = createRoute({
  method: 'get',
  path: '/api/candidates/{id}',
  request: {
    params: z.object({ id: z.string().openapi({ param: { name: 'id', in: 'path' }, example: '1' }) }),
  },
  responses: {
    200: {
      content: { 'application/json': { schema: CandidateSchema } },
      description: 'Get a candidate by ID',
    },
    404: { description: 'Candidate not found' },
  },
});

app.openapi(listCandidatesRoute, async (c) => {
  const allCandidates = await db.select().from(candidates);
  return c.json(allCandidates, 200);
});

app.openapi(createCandidateRoute, async (c) => {
  const body = c.req.valid('json');
  const [newCandidate] = await db.insert(candidates).values(body).returning();
  return c.json(newCandidate, 201);
});

app.openapi(getCandidateRoute, async (c) => {
  const { id } = c.req.valid('param');
  const [candidate] = await db.select().from(candidates).where(eq(candidates.id, Number(id)));
  if (!candidate) return c.json({ error: 'Candidate not found' }, 404);
  return c.json(candidate, 200);
});

export default app;

What does this code do?

This file mirrors the jobs route structure. The key difference is the CreateCandidateSchema uses .email() validation on the email field. Any request with an invalid email format will be automatically rejected with a validation error before it reaches the handler.

  • Save src/routes/candidates.ts.

Now you need to create the main app file that mounts these routes and serves the documentation.

  • Create a placeholder file for the applications route (you will build this in the next step) by running:
touch src/routes/applications.ts
  • Open src/routes/applications.ts and add a minimal export so the server can start:
import { OpenAPIHono } from '@hono/zod-openapi';

// Placeholder - full implementation in the next step
const app = new OpenAPIHono();

export default app;
  • Now replace the contents of src/index.ts with the full app entry point:
import 'dotenv/config';
import { serve } from '@hono/node-server';
import { OpenAPIHono } from '@hono/zod-openapi';
import { Scalar } from '@scalar/hono-api-reference';
import jobsApp from './routes/jobs.js';
import candidatesApp from './routes/candidates.js';
import applicationsApp from './routes/applications.js';

const app = new OpenAPIHono();

// Mount each route module onto the main app
app.route('/', jobsApp);
app.route('/', candidatesApp);
app.route('/', applicationsApp);

// Serve the raw OpenAPI JSON spec at /doc
app.doc('/doc', {
  openapi: '3.0.0',
  info: {
    version: '1.0.0',
    title: 'ATS API',
    description: 'Applicant Tracking System API',
  },
});

// Serve the interactive Scalar UI at /reference
app.get('/reference', Scalar({ url: '/doc' }));

// Root health check
app.get('/', (c) => c.json({ message: 'ATS API is running', docs: '/reference' }));

serve({ fetch: app.fetch, port: 3000 }, (info) => {
  console.log(`Server is running on http://localhost:${info.port}`);
});

What does this code do?

The app.route('/', jobsApp) calls mount each sub-app. All routes registered in those modules become available on the main app.

The app.doc('/doc', {...}) call generates an OpenAPI 3.0 JSON document from all registered routes. The Scalar({ url: '/doc' }) middleware renders a beautiful interactive UI where you can explore and test your endpoints.

  • Save src/index.ts.
  • Start the dev server by running:
npm run dev

You should see Server is running on http://localhost:3000 in your terminal.

Server won't start?

If you see a module-not-found error for ./routes/applications.js, make sure you created the placeholder file src/routes/applications.ts with the minimal export shown above.

If you see a database connection error, confirm your Docker PostgreSQL container is still running with docker ps.

Help me fix my server startup error.

✔️ Awesome, I've got everything!

Great. Double check you have saved all three route files and src/index.ts.

ⓧ I'd like to double check the full code

Here is src/routes/jobs.ts:

import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi';
import { db } from '../db/index.js';
import { jobs } from '../db/schema.js';
import { eq } from 'drizzle-orm';

const app = new OpenAPIHono();

const JobSchema = z.object({
  id: z.number().openapi({ example: 1 }),
  title: z.string().openapi({ example: 'Senior Engineer' }),
  department: z.string().openapi({ example: 'Engineering' }),
  description: z.string().nullable().openapi({ example: 'Build awesome things' }),
  status: z.string().openapi({ example: 'open' }),
  createdAt: z.string().openapi({ example: '2026-06-17T00:00:00.000Z' }),
}).openapi('Job');

const CreateJobSchema = z.object({
  title: z.string().min(1).openapi({ example: 'Senior Engineer' }),
  department: z.string().min(1).openapi({ example: 'Engineering' }),
  description: z.string().optional().openapi({ example: 'Build awesome things' }),
}).openapi('CreateJob');

const listJobsRoute = createRoute({
  method: 'get',
  path: '/api/jobs',
  responses: {
    200: {
      content: { 'application/json': { schema: z.array(JobSchema) } },
      description: 'List all jobs',
    },
  },
});

const createJobRoute = createRoute({
  method: 'post',
  path: '/api/jobs',
  request: { body: { content: { 'application/json': { schema: CreateJobSchema } } } },
  responses: {
    201: {
      content: { 'application/json': { schema: JobSchema } },
      description: 'Job created',
    },
  },
});

const getJobRoute = createRoute({
  method: 'get',
  path: '/api/jobs/{id}',
  request: {
    params: z.object({ id: z.string().openapi({ param: { name: 'id', in: 'path' }, example: '1' }) }),
  },
  responses: {
    200: {
      content: { 'application/json': { schema: JobSchema } },
      description: 'Get a job by ID',
    },
    404: { description: 'Job not found' },
  },
});

app.openapi(listJobsRoute, async (c) => {
  const allJobs = await db.select().from(jobs);
  return c.json(allJobs, 200);
});

app.openapi(createJobRoute, async (c) => {
  const body = c.req.valid('json');
  const [newJob] = await db.insert(jobs).values(body).returning();
  return c.json(newJob, 201);
});

app.openapi(getJobRoute, async (c) => {
  const { id } = c.req.valid('param');
  const [job] = await db.select().from(jobs).where(eq(jobs.id, Number(id)));
  if (!job) return c.json({ error: 'Job not found' }, 404);
  return c.json(job, 200);
});

export default app;

Here is src/routes/candidates.ts:

import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi';
import { db } from '../db/index.js';
import { candidates } from '../db/schema.js';
import { eq } from 'drizzle-orm';

const app = new OpenAPIHono();

const CandidateSchema = z.object({
  id: z.number().openapi({ example: 1 }),
  name: z.string().openapi({ example: 'Jane Doe' }),
  email: z.string().openapi({ example: 'jane@example.com' }),
  phone: z.string().nullable().openapi({ example: '+1234567890' }),
  createdAt: z.string().openapi({ example: '2026-06-17T00:00:00.000Z' }),
}).openapi('Candidate');

const CreateCandidateSchema = z.object({
  name: z.string().min(1).openapi({ example: 'Jane Doe' }),
  email: z.string().email().openapi({ example: 'jane@example.com' }),
  phone: z.string().optional().openapi({ example: '+1234567890' }),
}).openapi('CreateCandidate');

const listCandidatesRoute = createRoute({
  method: 'get',
  path: '/api/candidates',
  responses: {
    200: {
      content: { 'application/json': { schema: z.array(CandidateSchema) } },
      description: 'List all candidates',
    },
  },
});

const createCandidateRoute = createRoute({
  method: 'post',
  path: '/api/candidates',
  request: { body: { content: { 'application/json': { schema: CreateCandidateSchema } } } },
  responses: {
    201: {
      content: { 'application/json': { schema: CandidateSchema } },
      description: 'Candidate created',
    },
  },
});

const getCandidateRoute = createRoute({
  method: 'get',
  path: '/api/candidates/{id}',
  request: {
    params: z.object({ id: z.string().openapi({ param: { name: 'id', in: 'path' }, example: '1' }) }),
  },
  responses: {
    200: {
      content: { 'application/json': { schema: CandidateSchema } },
      description: 'Get a candidate by ID',
    },
    404: { description: 'Candidate not found' },
  },
});

app.openapi(listCandidatesRoute, async (c) => {
  const allCandidates = await db.select().from(candidates);
  return c.json(allCandidates, 200);
});

app.openapi(createCandidateRoute, async (c) => {
  const body = c.req.valid('json');
  const [newCandidate] = await db.insert(candidates).values(body).returning();
  return c.json(newCandidate, 201);
});

app.openapi(getCandidateRoute, async (c) => {
  const { id } = c.req.valid('param');
  const [candidate] = await db.select().from(candidates).where(eq(candidates.id, Number(id)));
  if (!candidate) return c.json({ error: 'Candidate not found' }, 404);
  return c.json(candidate, 200);
});

export default app;

Here is src/index.ts:

import 'dotenv/config';
import { serve } from '@hono/node-server';
import { OpenAPIHono } from '@hono/zod-openapi';
import { Scalar } from '@scalar/hono-api-reference';
import jobsApp from './routes/jobs.js';
import candidatesApp from './routes/candidates.js';
import applicationsApp from './routes/applications.js';

const app = new OpenAPIHono();

app.route('/', jobsApp);
app.route('/', candidatesApp);
app.route('/', applicationsApp);

app.doc('/doc', {
  openapi: '3.0.0',
  info: {
    version: '1.0.0',
    title: 'ATS API',
    description: 'Applicant Tracking System API',
  },
});

app.get('/reference', Scalar({ url: '/doc' }));

app.get('/', (c) => c.json({ message: 'ATS API is running', docs: '/reference' }));

serve({ fetch: app.fetch, port: 3000 }, (info) => {
  console.log(`Server is running on http://localhost:${info.port}`);
});
Test the API with curl and Scalar UI

With the server running, you can verify your endpoints respond correctly to valid and invalid requests.

  • Open a new terminal tab (keep the dev server running in the other tab).
  • Create a job by running:
curl -X POST http://localhost:3000/api/jobs \
  -H "Content-Type: application/json" \
  -d '{"title": "Backend Engineer", "department": "Engineering"}'

You should see a JSON response with the new job including its id, status defaulting to open, and a createdAt timestamp.

  • Verify that validation works by sending a request missing the required department field:
curl -X POST http://localhost:3000/api/jobs \
  -H "Content-Type: application/json" \
  -d '{"title": "Backend Engineer"}'

The API rejects this request with a validation error because department is required in the CreateJobSchema.

  • List all jobs to confirm your earlier create worked:
curl http://localhost:3000/api/jobs

You should see a JSON array containing the job you just created.

  • Open your browser and navigate to http://localhost:3000/reference to see the Scalar UI.

You should see a beautiful interactive documentation page listing all six endpoints (three for jobs, three for candidates) with their schemas and example values.

Why Scalar over Swagger UI?

Scalar provides a modern, responsive interface for exploring your API. It reads the same OpenAPI JSON that /doc serves and renders it as interactive documentation where you can send test requests directly from the browser.

Not seeing your endpoints in Scalar?

Check that http://localhost:3000/doc returns a JSON document with a paths object. If it is empty, make sure your route files export app as the default export and that src/index.ts mounts them with app.route('/', jobsApp).

Help me figure out why my Scalar docs page is empty.

Your Jobs and Candidates API is live and documented. Next up, you will build the Applications routes with pipeline stage transitions to move candidates through the hiring process.

Build the Applications API with Pipeline Transitions

Your Jobs and Candidates endpoints handle the static data in your ATS. Now you need the piece that connects them: applications.

An application represents a candidate applying for a specific job, then moving through stages like screening, interview, and offer. The challenge is enforcing which transitions are valid. You cannot jump from "applied" directly to "hired" in a real hiring process.

In this step, get ready to:
  • Define Zod schemas and OpenAPI routes for the Applications resource.
  • Implement pipeline stage transitions with a state machine validation map.
  • Test the full hiring flow from application to stage transitions.
Define the Application schemas and routes

The applications route file introduces a concept your other routes did not need: a state machine. Each application has a current pipeline stage, and only certain transitions between stages are allowed.

  • Create a new file src/routes/applications.ts and add the imports, app instance, and transition rules by pasting this code:
import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi';
import { db } from '../db/index.js';
import { applications } from '../db/schema.js';
import { eq } from 'drizzle-orm';

const app = new OpenAPIHono();

// Define which stage transitions are allowed
const VALID_TRANSITIONS: Record<string, string[]> = {
  applied: ['screening', 'rejected'],
  screening: ['interview', 'rejected'],
  interview: ['offer', 'rejected'],
  offer: ['hired', 'rejected'],
  hired: [],
  rejected: [],
};

What does this code do?

The VALID_TRANSITIONS object is a state machine map. Each key is a current stage, and the value is an array of stages it can move to.

For example, an application at the applied stage can only move to screening or rejected. Once an application reaches hired or rejected, it is a terminal state with no further transitions.

Why a state machine instead of free-form updates?

Without transition validation, any API consumer could set an application's stage to any value at any time. A recruiter could accidentally mark someone as "hired" before they even had an interview.

The state machine enforces the business rules of your hiring pipeline at the API layer. This makes invalid states impossible to reach through the API, regardless of which frontend or integration calls it.

  • Below the VALID_TRANSITIONS object, add the three Zod schemas that define your request and response shapes:
const ApplicationSchema = z.object({
  id: z.number().openapi({ example: 1 }),
  jobId: z.number().openapi({ example: 1 }),
  candidateId: z.number().openapi({ example: 1 }),
  stage: z.string().openapi({ example: 'applied' }),
  appliedAt: z.string().openapi({ example: '2026-06-17T00:00:00.000Z' }),
  updatedAt: z.string().openapi({ example: '2026-06-17T00:00:00.000Z' }),
}).openapi('Application');

const CreateApplicationSchema = z.object({
  jobId: z.number().openapi({ example: 1 }),
  candidateId: z.number().openapi({ example: 1 }),
}).openapi('CreateApplication');

const TransitionStageSchema = z.object({
  stage: z.enum(['applied', 'screening', 'interview', 'offer', 'hired', 'rejected']).openapi({ example: 'screening' }),
}).openapi('TransitionStage');

What does this code do?

Three schemas serve different purposes. ApplicationSchema describes what the API returns. CreateApplicationSchema defines what the client sends to create an application (just a job ID and candidate ID).

TransitionStageSchema uses z.enum() to restrict the stage value to only the six valid pipeline stages. Any other value is rejected before your handler code runs.

  • Below the schemas, add the three route definitions that wire up the HTTP methods, paths, and request/response contracts:
const listApplicationsRoute = createRoute({
  method: 'get',
  path: '/api/applications',
  responses: {
    200: {
      content: { 'application/json': { schema: z.array(ApplicationSchema) } },
      description: 'List all applications',
    },
  },
});

const createApplicationRoute = createRoute({
  method: 'post',
  path: '/api/applications',
  request: { body: { content: { 'application/json': { schema: CreateApplicationSchema } } } },
  responses: {
    201: {
      content: { 'application/json': { schema: ApplicationSchema } },
      description: 'Application created',
    },
  },
});

const transitionStageRoute = createRoute({
  method: 'patch',
  path: '/api/applications/{id}/stage',
  request: {
    params: z.object({ id: z.string().openapi({ param: { name: 'id', in: 'path' }, example: '1' }) }),
    body: { content: { 'application/json': { schema: TransitionStageSchema } } },
  },
  responses: {
    200: {
      content: { 'application/json': { schema: ApplicationSchema } },
      description: 'Stage transitioned',
    },
    400: { description: 'Invalid stage transition' },
    404: { description: 'Application not found' },
  },
});

What does this code do?

The transitionStageRoute uses the PATCH method because it partially updates the application (only the stage field). It also declares both a 400 and 404 response in its contract, which will appear in the OpenAPI docs.

The path /api/applications/{id}/stage follows REST conventions for acting on a sub-resource. You are updating the stage of a specific application.

Implement the stage transition logic

With the schemas and routes defined, you now need the handler functions that actually query the database and enforce transition rules.

  • Below the route definitions in src/routes/applications.ts, add the three handler implementations and the default export:
app.openapi(listApplicationsRoute, async (c) => {
  const allApplications = await db.select().from(applications);
  return c.json(allApplications, 200);
});

app.openapi(createApplicationRoute, async (c) => {
  const body = c.req.valid('json');
  const [newApp] = await db.insert(applications).values(body).returning();
  return c.json(newApp, 201);
});

app.openapi(transitionStageRoute, async (c) => {
  const { id } = c.req.valid('param');
  const { stage: newStage } = c.req.valid('json');

  // Look up the current application
  const [application] = await db.select().from(applications).where(eq(applications.id, Number(id)));
  if (!application) return c.json({ error: 'Application not found' }, 404);

  // Check if the transition is allowed
  const allowedTransitions = VALID_TRANSITIONS[application.stage];
  if (!allowedTransitions.includes(newStage)) {
    return c.json({ error: `Cannot transition from "${application.stage}" to "${newStage}"` }, 400);
  }

  // Perform the update
  const [updated] = await db
    .update(applications)
    .set({ stage: newStage, updatedAt: new Date() })
    .where(eq(applications.id, Number(id)))
    .returning();

  return c.json(updated, 200);
});

export default app;

What does the transition handler do?

The transitionStageRoute handler follows three steps. First, it fetches the application and returns a 404 if it does not exist.

Second, it looks up the application's current stage in the VALID_TRANSITIONS map. If the requested new stage is not in the allowed list, it returns a 400 with a descriptive error message.

Third, if the transition is valid, it updates the stage and updatedAt timestamp, then returns the updated application.

  • Save src/routes/applications.ts.
  • Your dev server should restart automatically. Check the terminal for any TypeScript errors.

Seeing a compile error?

Make sure the import { eq } from 'drizzle-orm' line is present at the top of your file. The transition handler uses eq for the WHERE clause.

Also verify your file uses the .js extension in import paths (../db/index.js), which is required for ESM module resolution.

help me debug a compile error in my applications route file

Now document the valid transitions so future developers (including yourself) can reference the pipeline design.

  • Create a docs folder in your project root (inside ats-api) and create a file called pipeline.md by running:
mkdir -p docs
  • Open docs/pipeline.md in your editor and paste this state diagram:
# Pipeline Stage Transitions

Valid transitions for the ATS hiring pipeline:

```
applied -> screening -> interview -> offer -> hired
   |           |            |          |
   v           v            v          v
rejected   rejected     rejected   rejected
```

## Rules

- Every application starts at `applied`.
- Each stage can only move forward to the next stage, or sideways to `rejected`.
- `hired` and `rejected` are terminal states (no further transitions).
- The API enforces these rules via the `VALID_TRANSITIONS` map in `src/routes/applications.ts`.

Why document the pipeline in a separate file?

This markdown file serves as living documentation for your pipeline design. When a new developer joins the project or you revisit this code months later, the state diagram makes the business logic immediately clear without reading through handler code.

  • Save docs/pipeline.md.

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before moving on to testing.

ⓧ I'd like to double check the full code

Here is the complete src/routes/applications.ts file:

import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi';
import { db } from '../db/index.js';
import { applications } from '../db/schema.js';
import { eq } from 'drizzle-orm';

const app = new OpenAPIHono();

const VALID_TRANSITIONS: Record<string, string[]> = {
  applied: ['screening', 'rejected'],
  screening: ['interview', 'rejected'],
  interview: ['offer', 'rejected'],
  offer: ['hired', 'rejected'],
  hired: [],
  rejected: [],
};

const ApplicationSchema = z.object({
  id: z.number().openapi({ example: 1 }),
  jobId: z.number().openapi({ example: 1 }),
  candidateId: z.number().openapi({ example: 1 }),
  stage: z.string().openapi({ example: 'applied' }),
  appliedAt: z.string().openapi({ example: '2026-06-17T00:00:00.000Z' }),
  updatedAt: z.string().openapi({ example: '2026-06-17T00:00:00.000Z' }),
}).openapi('Application');

const CreateApplicationSchema = z.object({
  jobId: z.number().openapi({ example: 1 }),
  candidateId: z.number().openapi({ example: 1 }),
}).openapi('CreateApplication');

const TransitionStageSchema = z.object({
  stage: z.enum(['applied', 'screening', 'interview', 'offer', 'hired', 'rejected']).openapi({ example: 'screening' }),
}).openapi('TransitionStage');

const listApplicationsRoute = createRoute({
  method: 'get',
  path: '/api/applications',
  responses: {
    200: {
      content: { 'application/json': { schema: z.array(ApplicationSchema) } },
      description: 'List all applications',
    },
  },
});

const createApplicationRoute = createRoute({
  method: 'post',
  path: '/api/applications',
  request: { body: { content: { 'application/json': { schema: CreateApplicationSchema } } } },
  responses: {
    201: {
      content: { 'application/json': { schema: ApplicationSchema } },
      description: 'Application created',
    },
  },
});

const transitionStageRoute = createRoute({
  method: 'patch',
  path: '/api/applications/{id}/stage',
  request: {
    params: z.object({ id: z.string().openapi({ param: { name: 'id', in: 'path' }, example: '1' }) }),
    body: { content: { 'application/json': { schema: TransitionStageSchema } } },
  },
  responses: {
    200: {
      content: { 'application/json': { schema: ApplicationSchema } },
      description: 'Stage transitioned',
    },
    400: { description: 'Invalid stage transition' },
    404: { description: 'Application not found' },
  },
});

app.openapi(listApplicationsRoute, async (c) => {
  const allApplications = await db.select().from(applications);
  return c.json(allApplications, 200);
});

app.openapi(createApplicationRoute, async (c) => {
  const body = c.req.valid('json');
  const [newApp] = await db.insert(applications).values(body).returning();
  return c.json(newApp, 201);
});

app.openapi(transitionStageRoute, async (c) => {
  const { id } = c.req.valid('param');
  const { stage: newStage } = c.req.valid('json');

  const [application] = await db.select().from(applications).where(eq(applications.id, Number(id)));
  if (!application) return c.json({ error: 'Application not found' }, 404);

  const allowedTransitions = VALID_TRANSITIONS[application.stage];
  if (!allowedTransitions.includes(newStage)) {
    return c.json({ error: `Cannot transition from "${application.stage}" to "${newStage}"` }, 400);
  }

  const [updated] = await db
    .update(applications)
    .set({ stage: newStage, updatedAt: new Date() })
    .where(eq(applications.id, Number(id)))
    .returning();

  return c.json(updated, 200);
});

export default app;
Test the full hiring flow

Now you will simulate a complete hiring workflow. You will create a job, create a candidate, submit an application, and move that application through pipeline stages.

  • Open a second terminal (keep your dev server running in the first one).
  • Create a job by running:
curl -X POST http://localhost:3000/api/jobs \
  -H "Content-Type: application/json" \
  -d '{"title": "Backend Engineer", "department": "Engineering"}'
  • You should see a JSON response with the new job, including an id field (likely 1).
  • Create a candidate by running:
curl -X POST http://localhost:3000/api/candidates \
  -H "Content-Type: application/json" \
  -d '{"name": "Jane Doe", "email": "jane@example.com"}'
  • You should see the new candidate returned with an id field.
  • Submit an application linking the candidate to the job by running:
curl -X POST http://localhost:3000/api/applications \
  -H "Content-Type: application/json" \
  -d '{"jobId": 1, "candidateId": 1}'
  • The response should show an application with "stage": "applied" and both appliedAt and updatedAt timestamps.
  • Move the application to the screening stage by running:
curl -X PATCH http://localhost:3000/api/applications/1/stage \
  -H "Content-Type: application/json" \
  -d '{"stage": "screening"}'
  • You should see the application returned with "stage": "screening" and an updated updatedAt timestamp.
  • Now test an invalid transition. Try to jump directly from screening to hired by running:
curl -X PATCH http://localhost:3000/api/applications/1/stage \
  -H "Content-Type: application/json" \
  -d '{"stage": "hired"}'
  • You should see a 400 error response with the message Cannot transition from "screening" to "hired".

What should I see?

The invalid transition returns a clear error because your state machine only allows screening to move to interview or rejected. This is exactly how a real ATS protects data integrity.

Getting a 500 error on POST /api/applications?

This usually means the job or candidate ID does not exist in the database. Make sure you ran the create job and create candidate commands first, and use the IDs from those responses.

If you restarted your Docker container, the database may have been wiped. Re-run npx drizzle-kit push to recreate the tables.

help me debug a 500 error on my applications endpoint

Your applications API is live with full pipeline enforcement. Next up, you will document your architecture decisions and verify that everything appears correctly in the auto-generated OpenAPI documentation.

Document Your Architecture Decisions

You have a fully working ATS API with validated endpoints, pipeline stage transitions, and auto-generated documentation.

In this final step, you will document the reasoning behind your technology choices using an Architecture Decision Record (ADR). Professional teams use ADRs to help future developers understand why a system was built the way it was. You will also verify that your OpenAPI specification is complete and export it for version control.

In this step, get ready to:
  • Write an Architecture Decision Record documenting your tech stack choices.
  • Verify your OpenAPI documentation is complete and export it to a file.
Create your Architecture Decision Record

An ADR captures the context, decision, and consequences of a significant architecture choice. It gives future developers (including your future self) a clear record of why you chose specific tools over alternatives.

  • Create a new file called docs/adr-001-tech-stack.md in the docs folder that already contains your pipeline.md.
  • Paste the following content into the file:
# ADR 001: Tech Stack Selection for ATS API

* Status: Accepted
* Date: 2026-06-17

## Context

We are building a lightweight Applicant Tracking System (ATS) API that manages
job postings, candidate profiles, and applications flowing through a hiring
pipeline. The API needs type-safe request validation, auto-generated
documentation, and a relational database with strict data integrity constraints.

We need to choose a web framework, an ORM/query builder, and a strategy for
representing the finite set of pipeline stages in the database.

## Decision

### Web Framework: Hono.js over Express

We will use Hono.js with the @hono/zod-openapi integration as our web framework.

Hono provides built-in TypeScript support with zero dependencies, and the
zod-openapi plugin lets us define validation schemas that double as OpenAPI
documentation. Express would require separate middleware for validation
(express-validator or Joi) and a separate tool for OpenAPI generation
(swagger-jsdoc or tsoa), increasing the dependency surface.

### ORM: Drizzle over Prisma

We will use Drizzle ORM as our database access layer.

Drizzle uses a SQL-like query builder that maps closely to the actual SQL
being generated. This makes it easier to reason about query performance and
debug issues. Prisma uses its own query language (PQL) that abstracts away
the SQL, which can make debugging harder for a system design learning project.
Drizzle also has a smaller footprint with no separate binary (prisma generate)
required.

### Pipeline Stages: PostgreSQL Enum over Application-Level Validation

We will use a PostgreSQL enum type (CREATE TYPE ... AS ENUM) for pipeline stages
rather than a plain text column with application-level validation only.

A database-level enum enforces data integrity at the storage layer. Even if a
bug in the application code bypasses validation, the database will reject
invalid stage values. This defense-in-depth approach is appropriate for a
hiring pipeline where data integrity directly impacts business decisions.

## Consequences

### Positive

* Single source of truth for validation and documentation (Zod schemas serve
  both purposes in Hono).
* SQL-like query builder reduces the mental translation between code and
  database operations.
* Database-level constraints prevent invalid pipeline states regardless of
  application bugs.
* Lightweight dependency footprint suitable for containerized deployment.

### Negative

* Hono has a smaller ecosystem than Express, so fewer community middleware
  packages are available.
* Drizzle has less documentation and fewer Stack Overflow answers compared
  to Prisma.
* PostgreSQL enums require a migration to add new stages. A text column with
  application validation would allow adding stages without a schema change.
* The team must learn Hono-specific patterns rather than leveraging existing
  Express knowledge.

What is an ADR?

An Architecture Decision Record is a short document that captures one significant design choice. The standard format includes Context (the forces and constraints), Decision (what you chose and why), and Consequences (the trade-offs you accepted).

ADRs are numbered sequentially. Teams store them in version control alongside the code so the reasoning lives with the implementation.

  • Save docs/adr-001-tech-stack.md.
  • Confirm the file exists by running:
ls docs/

You should see both adr-001-tech-stack.md and pipeline.md listed in the output.

Don't see the docs folder?

Make sure you created the docs/ folder in a previous step when you wrote pipeline.md. If it is missing, create it now with mkdir docs from your project root, then create the ADR file inside it.

Help me create the docs directory structure for my ADR files.

Verify and export the OpenAPI specification

Your API has been generating OpenAPI documentation automatically from the Zod schemas you defined in each route file. Now you will confirm that every endpoint appears correctly and export the spec for version control.

  • Make sure your dev server is running. If it stopped, start it again by running:
npm run dev
  • Open your browser and navigate to http://localhost:3000/doc to see the raw OpenAPI JSON specification.
  • Confirm you see JSON output that includes all your route paths: /api/jobs, /api/candidates, /api/applications, and /api/applications/{id}/stage.
  • Navigate to http://localhost:3000/reference to open the Scalar UI.
  • In the Scalar UI, verify that all endpoints are listed with their request bodies, response schemas, and example values.

What should I see in Scalar UI?

Scalar renders your OpenAPI spec as an interactive reference page. You should see sections for Jobs, Candidates, and Applications with expandable endpoints showing the schemas you defined (CreateJob, CreateCandidate, CreateApplication, TransitionStage).

Each endpoint shows the HTTP method, path, request body structure, and possible response codes. The example values come from the .openapi({ example: ... }) calls in your Zod schemas.

  • Export the OpenAPI JSON to a file for version control by running:
curl http://localhost:3000/doc > docs/openapi.json

Why export the OpenAPI spec?

Storing the generated spec in version control lets you track how your API contract changes over time. It also allows other tools (frontend code generators, API testing suites, CI contract tests) to consume your spec without needing to run the server.

  • Verify the export by running:
ls docs/

You should now see adr-001-tech-stack.md, openapi.json, and pipeline.md in the docs/ directory.

Seeing 'Connection refused' when running curl?

Your dev server must be running in another terminal tab for the curl command to work. Run npm run dev in a separate terminal first, then retry the curl command.

Help me export my OpenAPI spec from the running server.

Your ATS API is now fully documented with architecture decision records, pipeline state diagrams, and auto-generated OpenAPI documentation. This is the kind of system design work that demonstrates professional engineering practice.

Secret mission

Add Filtering and Pagination

Your GET /api/applications endpoint currently dumps every application in a flat array. In production, that's a performance disaster waiting to happen. Can you add query parameter filtering and offset-based pagination, complete with OpenAPI documentation?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally using Docker for PostgreSQL, so there are no ongoing cloud costs. The Docker container does use system memory while running.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • PostgreSQL Docker container (running on port 5432).
  • Local ats-api project folder on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building or plan to add more features to your ATS API.

  • Your PostgreSQL container continues running in the background and uses system memory.
  • Your project files remain on your Desktop ready for editing.
  • You can restart the dev server anytime with npm run dev from inside the ats-api folder.

Pause - I'll come back to this later

Stop the PostgreSQL container to free up memory. Your data stays intact inside the stopped container.

  • Find your PostgreSQL container's name or ID by running:
  • Look for the container using the postgres image in the output.
docker ps
  • Stop the container by running (replace the container name with yours):
docker stop [[POSTGRES_CONTAINER="your postgres container name or ID"]]
  • When you come back, restart it with:
docker start [[POSTGRES_CONTAINER="your postgres container name or ID"]]

Delete - I don't want to use this again

Remove all project resources and start fresh. This permanently deletes your database and project files.

  • Stop and remove the PostgreSQL Docker container by running:
docker ps
  • Find the container using the postgres image, then stop and remove it:
docker stop [[POSTGRES_CONTAINER="your postgres container name or ID"]]
docker rm -v [[POSTGRES_CONTAINER="your postgres container name or ID"]]
  • Delete the project folder from your Desktop by running:

macOS

rm -rf ~/Desktop/ats-api

Windows

rmdir /s /q %USERPROFILE%\Desktop\ats-api
  • Optionally, remove the downloaded PostgreSQL Docker image to reclaim disk space:
docker rmi postgres

Nice Work!

Nice Work!

Nice work! You just designed and built a fully functional Applicant Tracking System API from scratch, complete with a normalized database, validated endpoints, and auto-generated documentation.

You've learned how to:

  • Design and implement a normalized PostgreSQL schema with foreign keys, unique constraints, and enum-based pipeline stages using Drizzle ORM.
  • Build a type-safe REST API with Hono.js and Zod validation, including state machine logic for pipeline stage transitions.
  • Generate OpenAPI 3.0 documentation automatically from your route definitions and serve it through Scalar UI, plus document your architecture decisions in ADRs.
  • Secret Mission: Add query filtering and pagination to the applications endpoint with proper OpenAPI documentation.

Ready to quiz yourself?