Invoice Processing API with BullMQ
Build a REST API that processes invoices asynchronously using background job queues.
Introduction
30 Second Summary
Every time you order something online, an invoice gets created behind the scenes. The system calculates totals, checks for overdue payments, and sends reminders without slowing down the main application.
In this project, you will build an Invoice Processing API that dispatches background jobs through a message queue. You'll see how real production systems decouple expensive operations from request/response cycles.
What You'll Build
A REST API built with Express, TypeScript, and Prisma that creates invoices and processes them asynchronously through BullMQ background jobs.
By the end of this project, you'll have:
- An Express 5 REST API with typed routes for creating and managing clients and invoices.
- A Prisma schema with relational models (clients, invoices, line items) deployed to a local PostgreSQL database.
- A BullMQ worker that processes invoice jobs with retry logic, exponential backoff, and event tracking.
- A feature-based project structure with centralized error handling that mirrors how mid-level engineers organize real codebases.
- Secret Mission: Add a scheduled repeatable job that automatically detects overdue invoices and queues reminder notifications.
Are there any prerequisites?
You should have basic JavaScript or TypeScript knowledge and some familiarity with REST APIs. No prior experience with Redis, Prisma, or job queues is needed.
Before We Start
Before We Start...
Set Up the Project
Every backend API needs a foundation of tools working together. For this project, you need a JavaScript runtime, a relational database, and a message queue.
In this step, you will install and verify three core tools, then scaffold the project with all its dependencies and folder structure.
In this step, get ready to:
- Install and verify Node.js, PostgreSQL, and Redis.
- Initialize the project with TypeScript and Prisma.
- Create the feature-based folder structure and boot the Express server.
Install and verify Node.js, PostgreSQL, and Redis
This project depends on three tools running locally on your machine. Node.js runs your TypeScript code. PostgreSQL stores your invoice data. Redis powers the background job queue.
- Check your Node.js version by running this command:
node --version
✔️ I see version 18 or higher
Your Node.js version is compatible. Move on to the PostgreSQL check below.
ⓧ I see a version lower than 18
You need Node.js v18 or higher for this project. Visit the official Node.js downloads page and download the latest LTS version. After installing, close and reopen your terminal, then run node --version again to confirm.
ⓧ Command not found
Node.js is not installed. Visit the official Node.js downloads page and download the LTS installer for your operating system. Run through the installer with default settings, then close and reopen your terminal. Run node --version to confirm it shows v18 or higher.
- Check your PostgreSQL installation by running:
psql --version
✔️ I see a version number
PostgreSQL is installed. Move on to the Redis check below.
ⓧ Command not found
macOS
Install PostgreSQL using Homebrew or Postgres.app.
With Homebrew, run:
brew install postgresql@17
Alternatively, download Postgres.app for a lightweight menubar app that starts and stops PostgreSQL automatically.
Windows
Download the interactive installer from the official PostgreSQL Windows page. Run the installer with default settings. It includes the PostgreSQL server, pgAdmin, and the command-line tools.
After installation, close and reopen your terminal, then run psql --version again.
- Check that Redis is running by sending a ping:
redis-cli ping
✔️ I see PONG
Redis is running and responding. You are ready to create the project.
ⓧ Command not found or connection refused
macOS
Install Redis using Homebrew by running these commands:
brew tap redis/redis
brew install --cask redis
Start the Redis server:
redis-server $(brew --prefix)/etc/redis.conf
Open a new terminal tab and run redis-cli ping to confirm you see PONG.
Windows
Redis does not run natively on Windows. The official approach is to use Docker. Make sure Docker Desktop is installed and running, then open PowerShell and run:
docker run -d --name redis -p 6379:6379 redis
Once the container is running, install the redis-cli through WSL or connect from inside the container:
docker exec -it redis redis-cli ping
You should see PONG in the output.
Why three separate tools?
Production APIs separate concerns. The web server handles HTTP requests. The database persists data. The queue offloads slow work so requests stay fast.
This separation is the same architecture you will find at companies like Stripe and Shopify. Learning it now prepares you for mid-level backend roles.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir invoice-api
cd invoice-api
- Initialize a new npm project by running:
npm init -y
- Install the production dependencies by running:
npm install express@5.2.1 bullmq@5.78.0 ioredis@5.11.1 dotenv@16.5.0 @prisma/client@7.8.0 @prisma/adapter-pg@7.8.0 pg@8.16.0
- Install the development dependencies by running:
npm install -D typescript@5.7.3 tsx@4.19.4 @types/node@22.15.21 @types/express@5.0.2 @types/pg@8.11.14 prisma@7.8.0
What are these packages?
- express is the web framework that handles incoming HTTP requests.
- bullmq is the Redis-based queue library for background job processing.
- ioredis is the Redis client that BullMQ uses internally.
- prisma and @prisma/client provide the database ORM and type-safe query builder.
- tsx is the TypeScript runner that lets you execute .ts files directly without a separate compile step.
Seeing permission errors during install?
On macOS, if you see EACCES errors, your global npm directory may have incorrect permissions. Run the install commands with sudo prepended, or fix your npm permissions by following the official npm guide.
On Windows, run your terminal as Administrator if you see access-denied errors.
help me fix npm permission errors during install
Initialize TypeScript and Prisma
TypeScript adds type safety to your code, catching errors before runtime. Prisma provides a type-safe database client that works with your schema.
- Generate a TypeScript configuration file by running:
npx tsc --init
- Open the generated tsconfig.json file in your editor.
- Replace its entire contents with the following configuration:
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"outDir": "./dist",
"target": "ES2022"
}
}
What do these TypeScript options do?
- module and moduleResolution set to NodeNext enables native ES module support. This means you use import/export syntax and must include .js extensions in relative imports.
- strict turns on all type-checking flags, catching common bugs at compile time.
- outDir tells TypeScript where to put compiled JavaScript files.
- target set to ES2022 means the output uses modern JavaScript features that Node.js 18+ supports natively.
- Open package.json and add "type": "module" at the top level (after the "version" field).
- Also replace the "scripts" section with:
"scripts": {
"dev": "tsx src/server.ts",
"build": "tsc"
}
Why type module?
Setting "type": "module" in package.json tells Node.js to treat all .js files as ES modules. Prisma v7 requires this setting.
- Initialize Prisma with a PostgreSQL datasource by running:
npx prisma init --datasource-provider postgresql --output ../generated/prisma
What does this command do?
This creates a prisma/ folder with a schema.prisma file and a .env file. The --output flag tells Prisma where to place the generated client code later.
- Open the .env file that Prisma created in your project root.
- Replace its contents with the following:
DATABASE_URL="postgresql://user:password@localhost:5432/invoice_api"
REDIS_URL="redis://localhost:6379"
Adjust credentials for your local setup
Replace user and password in the DATABASE_URL with your local PostgreSQL username and password. On macOS with Homebrew or Postgres.app, your username is often your system username with no password required.
- Save tsconfig.json, package.json, and .env.
Create the folder structure and start the server
Production codebases organize files by feature, not by type. Instead of dumping all routes into one folder and all services into another, each feature (clients, invoices, jobs) gets its own module folder.
- Create all the project directories by running:
mkdir -p src/config src/modules/clients src/modules/invoices src/modules/jobs src/common/errors src/common/middleware src/lib
Why feature-based folders?
A feature-based monolith groups all related code (routes, controllers, services) inside one folder per domain concept. When you open src/modules/invoices/, everything about invoices is right there.
This pattern scales better than layer-based structures and mirrors how mid-level engineers organize real codebases at companies.
- Create the environment configuration file by adding the following to src/config/env.ts:
import 'dotenv/config';
export const env = {
DATABASE_URL: process.env.DATABASE_URL!,
REDIS_URL: process.env.REDIS_URL!,
PORT: process.env.PORT || '3000',
};
What does this code do?
- import 'dotenv/config' loads your .env file into process.env as soon as this module is imported.
- The env object centralizes all environment variables in one place. The ! tells TypeScript these values will always exist at runtime.
- Create the Redis connection file by adding the following to src/lib/redis.ts:
import IORedis from 'ioredis';
import { env } from '../config/env.js';
export const redisConnection = new IORedis(env.REDIS_URL, {
maxRetriesPerRequest: null,
});
What does this code do?
- This creates a shared Redis connection instance using ioredis.
- maxRetriesPerRequest: null is required by BullMQ. It prevents ioredis from throwing errors when a request takes too long, letting BullMQ handle its own retry logic.
- Notice the .js extension in the import path. With ESM (NodeNext module resolution), you must include file extensions in relative imports even when the source file is .ts.
- Create the Express app by adding the following to src/app.ts:
import express from 'express';
const app = express();
app.use(express.json());
export default app;
What does this code do?
- This creates and configures the Express application instance.
- express.json() is middleware that parses incoming JSON request bodies so you can access them via req.body.
- Exporting the app separately from the server lets you import it for testing later without starting a listener.
- Create the server entry point by adding the following to src/server.ts:
import app from './app.js';
import { env } from './config/env.js';
app.listen(env.PORT, () => {
console.log(`Server running on port ${env.PORT}`);
});
What does this code do?
- This file bootstraps the server by importing the Express app and starting it on the configured port.
- app.listen() binds the app to the port and begins accepting incoming HTTP connections.
- The callback logs a message confirming the server started successfully.
- Create placeholder files for the modules you will build in later steps:
touch src/modules/clients/.gitkeep src/modules/invoices/.gitkeep src/modules/jobs/.gitkeep src/common/errors/.gitkeep src/common/middleware/.gitkeep src/lib/prisma.ts
- Save all files.
- Start the development server by running:
npx tsx src/server.ts
You should see Server running on port 3000 printed in your terminal.
Server not starting?
- If you see a module-not-found error, double-check that your import paths include the .js extension (e.g., './app.js' not './app').
- If you see an ERR_PACKAGE_JSON_INVALID error, confirm that "type": "module" is inside your package.json at the top level.
- If you see a Redis connection error, make sure Redis is running. The server will still start, but the Redis connection will retry in the background.
help me debug why my Express server won't start
- Stop the server by pressing Ctrl+C in your terminal.
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files.
ⓧ I'd like to double check the full code
{
"name": "invoice-api",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx src/server.ts",
"build": "tsc"
},
"dependencies": {
"@prisma/adapter-pg": "7.8.0",
"@prisma/client": "7.8.0",
"bullmq": "5.78.0",
"dotenv": "16.5.0",
"express": "5.2.1",
"ioredis": "5.11.1",
"pg": "8.16.0"
},
"devDependencies": {
"@types/express": "5.0.2",
"@types/node": "22.15.21",
"@types/pg": "8.11.14",
"prisma": "7.8.0",
"tsx": "4.19.4",
"typescript": "5.7.3"
}
}
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"outDir": "./dist",
"target": "ES2022"
}
}
DATABASE_URL="postgresql://user:password@localhost:5432/invoice_api"
REDIS_URL="redis://localhost:6379"
import 'dotenv/config';
export const env = {
DATABASE_URL: process.env.DATABASE_URL!,
REDIS_URL: process.env.REDIS_URL!,
PORT: process.env.PORT || '3000',
};
import IORedis from 'ioredis';
import { env } from '../config/env.js';
export const redisConnection = new IORedis(env.REDIS_URL, {
maxRetriesPerRequest: null,
});
import express from 'express';
const app = express();
app.use(express.json());
export default app;
import app from './app.js';
import { env } from './config/env.js';
app.listen(env.PORT, () => {
console.log(`Server running on port ${env.PORT}`);
});
Your development environment is ready. Next up, you will define the database schema and seed it with test data.
Define the Database Schema and Seed Data
Your Express server is running and the project structure is in place. Now you need a database layer to store clients and their invoices.
In this step, you will define a Prisma schema with relational models, run a migration to create your PostgreSQL tables, and seed the database with test data.
In this step, get ready to:
- Define Client, Invoice, and LineItem models with proper relations.
- Run a migration and generate the Prisma client.
- Set up the Prisma client singleton and seed the database with test data.
Define the Prisma schema with relational models
The Prisma schema is where you describe your database tables, their columns, and how they relate to each other. Prisma uses this file to generate type-safe queries and handle migrations.
- Open prisma/schema.prisma in your editor and replace its entire contents with the following schema:
generator client {
provider = "prisma-client-js"
output = "../generated/prisma"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// Tracks invoice lifecycle from creation to payment
enum InvoiceStatus {
DRAFT
PENDING
PAID
OVERDUE
}
// A client who receives invoices
model Client {
id String @id @default(cuid())
name String
email String @unique
invoices Invoice[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// An invoice belonging to a single client
model Invoice {
id String @id @default(cuid())
clientId String
client Client @relation(fields: [clientId], references: [id])
status InvoiceStatus @default(DRAFT)
dueDate DateTime
lineItems LineItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// A single billable entry on an invoice
model LineItem {
id String @id @default(cuid())
invoiceId String
invoice Invoice @relation(fields: [invoiceId], references: [id])
description String
quantity Int
unitPrice Float
}
What does this schema do?
This schema defines three related models that mirror a real invoicing system:
- Client stores company names and emails. Each client can have many invoices (one-to-many relation via the invoices field).
- Invoice tracks status through its lifecycle using the InvoiceStatus enum. Each invoice belongs to one client and has many line items.
- LineItem represents a single billable entry with a description, quantity, and unit price. Each line item belongs to one invoice.
- Save prisma/schema.prisma.
Seeing a syntax error in the schema?
Make sure every opening brace { has a matching closing brace }. Also check that the @relation annotations reference the correct field names.
help me fix a syntax error in my Prisma schema
Run the migration and generate the Prisma client
A migration translates your schema into SQL statements that create tables in your PostgreSQL database. After migrating, you generate the Prisma client so your TypeScript code can query those tables with full type safety.
- Apply the migration to your database by running this command:
npx prisma migrate dev --name init
What does this command do?
This creates a migrations folder inside prisma/ with the SQL needed to create your tables. It then applies that SQL to the local PostgreSQL database specified in DATABASE_URL.
- You should see output confirming the migration was applied successfully. A new prisma/migrations/ folder appears in your project.
- Generate the Prisma client by running:
npx prisma generate
Why run generate separately?
In Prisma v7, migrate dev no longer automatically triggers prisma generate. You need to run it explicitly to produce the type-safe client in the generated/prisma folder.
- Confirm you see a generated/prisma/ folder at your project root after the command completes.
Migration failed with a connection error?
Make sure PostgreSQL is running locally and that the DATABASE_URL in your .env file matches your local PostgreSQL credentials (username, password, port, and database name).
help me fix my Prisma migration connection error
Set up the Prisma client singleton and seed the database
Your API routes will need a shared Prisma client instance to query the database. Creating a singleton ensures you reuse one connection pool across all requests instead of opening a new connection per query.
- Open src/lib/prisma.ts and add the following code:
import { PrismaClient } from '../../generated/prisma/index.js';
import { PrismaPg } from '@prisma/adapter-pg';
import { env } from '../config/env.js';
// Create a PostgreSQL adapter with the connection string
const adapter = new PrismaPg({ connectionString: env.DATABASE_URL });
// Export a single Prisma client instance for the entire app
export const prisma = new PrismaClient({ adapter });
What is the PrismaPg adapter?
Prisma v7 requires a driver adapter to connect to your database. The PrismaPg adapter uses the pg package under the hood to manage connections.
By passing your DATABASE_URL to it, the adapter creates and manages a connection pool that your entire app shares through this single export.
- Save src/lib/prisma.ts.
Now create a seed script to populate your database with test clients.
- Create a new file called prisma/seed.ts and add the following code:
import { PrismaClient } from '../generated/prisma/index.js';
const prisma = new PrismaClient();
// Insert test clients into the database
async function main() {
await prisma.client.createMany({
data: [
{ name: 'Acme Corp', email: 'billing@acme.com' },
{ name: 'Globex Inc', email: 'accounts@globex.com' },
{ name: 'Initech LLC', email: 'finance@initech.com' },
],
});
console.log('Seed complete');
}
main().finally(() => prisma.$disconnect());
What does this seed script do?
The createMany call inserts three test clients in a single database query. The .finally() block ensures the database connection closes cleanly whether the script succeeds or fails.
- Save prisma/seed.ts.
- Open package.json and add a seed script to the scripts section, plus a prisma configuration block at the top level:
{
"scripts": {
"dev": "tsx src/server.ts",
"build": "tsc",
"seed": "tsx prisma/seed.ts"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
}
}
Why two seed entries?
The scripts.seed entry lets you run the seed directly with npm run seed. The top-level prisma.seed entry tells the Prisma CLI which command to execute when you run npx prisma db seed.
- Save package.json.
- Run the seed script by running:
npm run seed
- You should see Seed complete printed in your terminal.
Seeing a module not found error?
Make sure you ran npx prisma generate before running the seed. The seed script imports from ../generated/prisma/index.js which only exists after generation.
help me fix a module not found error in my Prisma seed script
- Verify your database tables and seed data by running:
npx prisma studio
- Prisma Studio opens in your browser. Click on the Client table and confirm you see three rows: Acme Corp, Globex Inc, and Initech LLC.
- Close Prisma Studio by pressing Ctrl+C in your terminal when you are done.
Prisma Studio shows empty tables?
Run npm run seed again and confirm you see Seed complete in the terminal before reopening Studio.
help me figure out why my Prisma seed data is not showing up
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files before moving on.
ⓧ I'd like to double check the full code
Here is the complete code for each file you created or modified in this step:
generator client {
provider = "prisma-client-js"
output = "../generated/prisma"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum InvoiceStatus {
DRAFT
PENDING
PAID
OVERDUE
}
model Client {
id String @id @default(cuid())
name String
email String @unique
invoices Invoice[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Invoice {
id String @id @default(cuid())
clientId String
client Client @relation(fields: [clientId], references: [id])
status InvoiceStatus @default(DRAFT)
dueDate DateTime
lineItems LineItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model LineItem {
id String @id @default(cuid())
invoiceId String
invoice Invoice @relation(fields: [invoiceId], references: [id])
description String
quantity Int
unitPrice Float
}
import { PrismaClient } from '../generated/prisma/index.js';
const prisma = new PrismaClient();
async function main() {
await prisma.client.createMany({
data: [
{ name: 'Acme Corp', email: 'billing@acme.com' },
{ name: 'Globex Inc', email: 'accounts@globex.com' },
{ name: 'Initech LLC', email: 'finance@initech.com' },
],
});
console.log('Seed complete');
}
main().finally(() => prisma.$disconnect());
import { PrismaClient } from '../../generated/prisma/index.js';
import { PrismaPg } from '@prisma/adapter-pg';
import { env } from '../config/env.js';
const adapter = new PrismaPg({ connectionString: env.DATABASE_URL });
export const prisma = new PrismaClient({ adapter });
{
"name": "invoice-api",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx src/server.ts",
"build": "tsc",
"seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@prisma/adapter-pg": "7.8.0",
"@prisma/client": "7.8.0",
"bullmq": "5.78.0",
"dotenv": "16.5.0",
"express": "5.2.1",
"ioredis": "5.11.1",
"pg": "8.16.0"
},
"devDependencies": {
"@types/express": "5.0.2",
"@types/node": "22.15.21",
"@types/pg": "8.11.14",
"prisma": "7.8.0",
"tsx": "4.19.4",
"typescript": "5.7.3"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
}
}
Your database is live with tables and test data. Next up, you will build the REST API endpoints that let you create clients and invoices through HTTP requests.
Build the REST API Endpoints
Your database is seeded with test clients and ready to serve data. Now you need to expose that data through HTTP endpoints so external consumers (frontends, mobile apps, other services) can interact with your invoice system.
In this step, you will build the clients and invoices modules following a controller-service-routes pattern. You will also add centralized error handling that takes advantage of Express 5's native async error forwarding.
In this step, get ready to:
- Create a clients module with list and create endpoints.
- Create an invoices module with create, get-by-ID, and list-with-filter endpoints.
- Add typed error classes and centralized error handling middleware.
Create the clients module
Each feature module in this project follows a three-file pattern: a service (database logic), a controller (request handling), and a routes file (URL mapping). The clients module is the simplest, so it is a good place to establish this pattern.
- Create the file src/modules/clients/clients.service.ts with the following code:
import { prisma } from '../../lib/prisma.js';
// Fetch all clients from the database
export async function getAllClients() {
return prisma.client.findMany();
}
// Create a new client record with the given name and email
export async function createClient(data: { name: string; email: string }) {
return prisma.client.create({ data });
}
What does this code do?
The service layer is responsible for all database interactions. It imports the shared prisma instance and exposes two functions: one that fetches every client, and one that creates a new client from a name/email pair.
Keeping database logic in a dedicated service makes it reusable. Controllers call the service without needing to know anything about Prisma or the database.
- Create the file src/modules/clients/clients.controller.ts with the following code:
import { Request, Response, NextFunction } from 'express';
import * as clientsService from './clients.service.js';
import { ValidationError } from '../../common/errors/AppError.js';
// Handle GET /api/clients - return all clients
export async function listClients(req: Request, res: Response, next: NextFunction) {
const clients = await clientsService.getAllClients();
res.json(clients);
}
// Handle POST /api/clients - create a new client
export async function createClient(req: Request, res: Response, next: NextFunction) {
const { name, email } = req.body;
if (!name || !email) throw new ValidationError('name and email are required');
const client = await clientsService.createClient({ name, email });
res.status(201).json(client);
}
What does this code do?
The controller handles HTTP concerns: reading request data, validating input, calling the service, and sending a response. If name or email is missing, it throws a ValidationError (you will create this class shortly).
Express 5 automatically catches thrown errors in async handlers and forwards them to the error middleware. You do not need manual try/catch blocks around your controller logic.
- Create the file src/modules/clients/clients.routes.ts with the following code:
import { Router } from 'express';
import * as clientsController from './clients.controller.js';
// Map HTTP methods to controller handlers
const router = Router();
router.get('/', clientsController.listClients);
router.post('/', clientsController.createClient);
export default router;
What does this code do?
The routes file creates an Express Router and maps each HTTP method to the corresponding controller function. The paths here are relative. When this router is mounted at /api/clients in app.ts, the full paths become GET /api/clients and POST /api/clients.
- Save all three files.
You will not see a change yet. The routes are not wired into the app until the final substep.
Create the invoices module
The invoices module is more complex. It handles nested data (line items), computes totals, and supports filtering by status. The same service-controller-routes pattern applies.
- Create the file src/modules/invoices/invoices.service.ts with the following code:
import { prisma } from '../../lib/prisma.js';
import { InvoiceStatus } from '../../../generated/prisma/index.js';
import { NotFoundError, ValidationError } from '../../common/errors/AppError.js';
// Create an invoice with nested line items in a single transaction
export async function createInvoice(data: {
clientId: string;
dueDate: string;
lineItems: { description: string; quantity: number; unitPrice: number }[];
}) {
if (!data.lineItems || data.lineItems.length === 0) {
throw new ValidationError('At least one line item is required');
}
return prisma.invoice.create({
data: {
clientId: data.clientId,
dueDate: new Date(data.dueDate),
lineItems: { create: data.lineItems },
},
include: { lineItems: true },
});
}
// Fetch a single invoice by ID and compute the total from line items
export async function getInvoiceById(id: string) {
const invoice = await prisma.invoice.findUnique({
where: { id },
include: { lineItems: true, client: true },
});
if (!invoice) throw new NotFoundError(`Invoice ${id} not found`);
const total = invoice.lineItems.reduce(
(sum, item) => sum + item.quantity * item.unitPrice,
0
);
return { ...invoice, total };
}
// List all invoices, optionally filtered by status
export async function listInvoices(status?: InvoiceStatus) {
return prisma.invoice.findMany({
where: status ? { status } : undefined,
include: { lineItems: true, client: true },
});
}
What does this code do?
- createInvoice validates that at least one line item exists, then uses Prisma's nested create syntax to insert the invoice and its line items in a single database call.
- getInvoiceById fetches the invoice with its related line items and client, then computes a total by multiplying quantity by unit price for each item.
- listInvoices accepts an optional InvoiceStatus filter. If no status is passed, it returns all invoices.
- Create the file src/modules/invoices/invoices.controller.ts with the following code:
import { Request, Response, NextFunction } from 'express';
import * as invoicesService from './invoices.service.js';
import { InvoiceStatus } from '../../../generated/prisma/index.js';
// Handle POST /api/invoices - create a new invoice with line items
export async function createInvoice(req: Request, res: Response, next: NextFunction) {
const invoice = await invoicesService.createInvoice(req.body);
res.status(201).json(invoice);
}
// Handle GET /api/invoices/:id - return a single invoice with computed total
export async function getInvoice(req: Request, res: Response, next: NextFunction) {
const invoice = await invoicesService.getInvoiceById(req.params.id);
res.json(invoice);
}
// Handle GET /api/invoices - list invoices with optional status filter
export async function listInvoices(req: Request, res: Response, next: NextFunction) {
const status = req.query.status as InvoiceStatus | undefined;
const invoices = await invoicesService.listInvoices(status);
res.json(invoices);
}
What does this code do?
Each controller function extracts the relevant data from the request (body, params, or query string) and delegates to the service. The status query parameter is cast to the InvoiceStatus enum type so Prisma can use it directly in a where clause.
- Create the file src/modules/invoices/invoices.routes.ts with the following code:
import { Router } from 'express';
import * as invoicesController from './invoices.controller.js';
// Map invoice endpoints to controller handlers
const router = Router();
router.post('/', invoicesController.createInvoice);
router.get('/:id', invoicesController.getInvoice);
router.get('/', invoicesController.listInvoices);
export default router;
Why is the :id route above the / route?
Express matches routes in declaration order. Placing /:id before / for GET requests ensures that a request like GET /api/invoices/abc123 hits the specific-invoice handler instead of the list handler.
- Save all three invoices files.
You will not see a change yet. The next substep wires everything together.
Add centralized error handling and wire up the app
Your controllers throw typed errors (ValidationError, NotFoundError) but those classes do not exist yet. You also need middleware that catches every thrown error and returns a consistent JSON response.
Why does Express 5 make this easier?
In Express 5, route handlers and middleware that return a Promise automatically forward thrown errors or rejected promises to the error-handling middleware. You do not need to wrap every handler in try/catch or install extra packages.
This means you can simply throw a custom error anywhere in your async code and Express will route it to your centralized error handler.
- Create the file src/common/errors/AppError.ts with the following code:
// Base error class with an HTTP status code
export class AppError extends Error {
constructor(
public message: string,
public statusCode: number = 500
) {
super(message);
this.name = 'AppError';
}
}
// 404 - resource not found
export class NotFoundError extends AppError {
constructor(message = 'Resource not found') {
super(message, 404);
this.name = 'NotFoundError';
}
}
// 400 - validation failure
export class ValidationError extends AppError {
constructor(message = 'Validation failed') {
super(message, 400);
this.name = 'ValidationError';
}
}
What does this code do?
- AppError is the base class that pairs an error message with an HTTP status code. Every custom error in your app extends it.
- NotFoundError defaults to status 404 and is thrown when a requested resource does not exist.
- ValidationError defaults to status 400 and is thrown when required fields are missing or data is invalid.
- Create the file src/common/middleware/errorHandler.ts with the following code:
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../errors/AppError.js';
// Centralized error handler - must have 4 parameters for Express to recognize it
export function errorHandler(
err: unknown,
req: Request,
res: Response,
next: NextFunction
): void {
if (err instanceof AppError) {
res.status(err.statusCode).json({ error: err.name, message: err.message });
return;
}
console.error(err);
res.status(500).json({ error: 'InternalServerError', message: 'Something went wrong' });
}
What does this code do?
Express identifies error-handling middleware by its four-parameter signature: (err, req, res, next). If the error is one of your custom AppError subclasses, the handler returns the appropriate status code and a structured JSON body. For unexpected errors, it logs the full error and returns a generic 500 response.
- Open src/app.ts and replace the entire file with this updated version that registers routes and the error handler:
import express from 'express';
import clientsRouter from './modules/clients/clients.routes.js';
import invoicesRouter from './modules/invoices/invoices.routes.js';
import { errorHandler } from './common/middleware/errorHandler.js';
const app = express();
app.use(express.json());
// Mount feature routers at their base paths
app.use('/api/clients', clientsRouter);
app.use('/api/invoices', invoicesRouter);
// Error handler must be registered LAST
app.use(errorHandler);
export default app;
What does this code do?
- The two app.use calls mount the clients router at /api/clients and the invoices router at /api/invoices.
- The errorHandler is registered after all routes. Express only sends errors to middleware that appears after the route that threw.
- Save src/app.ts.
- Start the server by running:
npx tsx src/server.ts
- In a second terminal, test the clients endpoint by running:
curl http://localhost:3000/api/clients
You should see a JSON array containing the seed clients (Acme Corp, Globex Inc, Initech LLC).
- Test creating an invoice by running:
curl -X POST http://localhost:3000/api/invoices \
-H "Content-Type: application/json" \
-d '{"clientId":"REPLACE_WITH_CLIENT_ID","dueDate":"2026-07-01","lineItems":[{"description":"Web Development","quantity":10,"unitPrice":150}]}'
Finding a client ID
Copy one of the id values from the GET /api/clients response and paste it in place of REPLACE_WITH_CLIENT_ID in the curl command above.
You should see a JSON response with the new invoice, its line items, and a status of DRAFT.
- Test error handling by sending a request with no line items:
curl -X POST http://localhost:3000/api/invoices \
-H "Content-Type: application/json" \
-d '{"clientId":"test","dueDate":"2026-07-01","lineItems":[]}'
You should see a 400 response with the body {"error":"ValidationError","message":"At least one line item is required"}.
Server not starting or seeing import errors?
- Check that every import path ends with .js (not .ts). ESM with TypeScript requires the .js extension in import statements.
- Make sure src/common/errors/ and src/common/middleware/ directories exist. Create them if you missed them in the project setup.
- Confirm PostgreSQL is still running. If the server crashes with a connection error, restart PostgreSQL and try again.
help me debug my Express server startup error
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files before moving on.
ⓧ I'd like to double check the full code
Here is src/modules/clients/clients.service.ts:
import { prisma } from '../../lib/prisma.js';
export async function getAllClients() {
return prisma.client.findMany();
}
export async function createClient(data: { name: string; email: string }) {
return prisma.client.create({ data });
}
Here is src/modules/clients/clients.controller.ts:
import { Request, Response, NextFunction } from 'express';
import * as clientsService from './clients.service.js';
import { ValidationError } from '../../common/errors/AppError.js';
export async function listClients(req: Request, res: Response, next: NextFunction) {
const clients = await clientsService.getAllClients();
res.json(clients);
}
export async function createClient(req: Request, res: Response, next: NextFunction) {
const { name, email } = req.body;
if (!name || !email) throw new ValidationError('name and email are required');
const client = await clientsService.createClient({ name, email });
res.status(201).json(client);
}
Here is src/modules/clients/clients.routes.ts:
import { Router } from 'express';
import * as clientsController from './clients.controller.js';
const router = Router();
router.get('/', clientsController.listClients);
router.post('/', clientsController.createClient);
export default router;
Here is src/modules/invoices/invoices.service.ts:
import { prisma } from '../../lib/prisma.js';
import { InvoiceStatus } from '../../../generated/prisma/index.js';
import { NotFoundError, ValidationError } from '../../common/errors/AppError.js';
export async function createInvoice(data: {
clientId: string;
dueDate: string;
lineItems: { description: string; quantity: number; unitPrice: number }[];
}) {
if (!data.lineItems || data.lineItems.length === 0) {
throw new ValidationError('At least one line item is required');
}
return prisma.invoice.create({
data: {
clientId: data.clientId,
dueDate: new Date(data.dueDate),
lineItems: { create: data.lineItems },
},
include: { lineItems: true },
});
}
export async function getInvoiceById(id: string) {
const invoice = await prisma.invoice.findUnique({
where: { id },
include: { lineItems: true, client: true },
});
if (!invoice) throw new NotFoundError(`Invoice ${id} not found`);
const total = invoice.lineItems.reduce(
(sum, item) => sum + item.quantity * item.unitPrice,
0
);
return { ...invoice, total };
}
export async function listInvoices(status?: InvoiceStatus) {
return prisma.invoice.findMany({
where: status ? { status } : undefined,
include: { lineItems: true, client: true },
});
}
Here is src/modules/invoices/invoices.controller.ts:
import { Request, Response, NextFunction } from 'express';
import * as invoicesService from './invoices.service.js';
import { InvoiceStatus } from '../../../generated/prisma/index.js';
export async function createInvoice(req: Request, res: Response, next: NextFunction) {
const invoice = await invoicesService.createInvoice(req.body);
res.status(201).json(invoice);
}
export async function getInvoice(req: Request, res: Response, next: NextFunction) {
const invoice = await invoicesService.getInvoiceById(req.params.id);
res.json(invoice);
}
export async function listInvoices(req: Request, res: Response, next: NextFunction) {
const status = req.query.status as InvoiceStatus | undefined;
const invoices = await invoicesService.listInvoices(status);
res.json(invoices);
}
Here is src/modules/invoices/invoices.routes.ts:
import { Router } from 'express';
import * as invoicesController from './invoices.controller.js';
const router = Router();
router.post('/', invoicesController.createInvoice);
router.get('/:id', invoicesController.getInvoice);
router.get('/', invoicesController.listInvoices);
export default router;
Here is src/common/errors/AppError.ts:
export class AppError extends Error {
constructor(
public message: string,
public statusCode: number = 500
) {
super(message);
this.name = 'AppError';
}
}
export class NotFoundError extends AppError {
constructor(message = 'Resource not found') {
super(message, 404);
this.name = 'NotFoundError';
}
}
export class ValidationError extends AppError {
constructor(message = 'Validation failed') {
super(message, 400);
this.name = 'ValidationError';
}
}
Here is src/common/middleware/errorHandler.ts:
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../errors/AppError.js';
export function errorHandler(
err: unknown,
req: Request,
res: Response,
next: NextFunction
): void {
if (err instanceof AppError) {
res.status(err.statusCode).json({ error: err.name, message: err.message });
return;
}
console.error(err);
res.status(500).json({ error: 'InternalServerError', message: 'Something went wrong' });
}
Here is src/app.ts:
import express from 'express';
import clientsRouter from './modules/clients/clients.routes.js';
import invoicesRouter from './modules/invoices/invoices.routes.js';
import { errorHandler } from './common/middleware/errorHandler.js';
const app = express();
app.use(express.json());
app.use('/api/clients', clientsRouter);
app.use('/api/invoices', invoicesRouter);
app.use(errorHandler);
export default app;
Your API now serves clients and invoices with proper error responses. Next up, you will add background job processing with BullMQ so that creating an invoice triggers asynchronous work like calculating totals and checking for overdue payments.
Implement BullMQ Job Processing
Your REST API can create invoices, but right now processing happens instantly inside the request. In production, expensive operations like calculating totals, checking payment status, and sending notifications should happen in the background.
In this step, you will set up BullMQ to process invoice jobs asynchronously. When a new invoice is created, the API will dispatch a background job that calculates the total and updates the invoice status.
In this step, get ready to:
- Create a BullMQ queue and worker for invoice processing.
- Dispatch background jobs from the invoice creation endpoint.
- Add event listeners that track job completion, failure, and retries.
Set up the invoice processing queue and worker
A queue holds jobs waiting to be processed. A worker pulls jobs off the queue and runs your processing logic. You will create both inside the src/modules/jobs/ folder.
- Create a new file at src/modules/jobs/queues.ts and add the following code:
import { Queue } from 'bullmq';
import { redisConnection } from '../../lib/redis.js';
// Create the invoice processing queue backed by Redis
export const invoiceQueue = new Queue('invoice-processing', {
connection: redisConnection,
});
What does this code do?
- It imports Queue from BullMQ and the shared Redis connection you set up earlier.
- It creates a queue named invoice-processing that BullMQ uses to track pending jobs in Redis.
- The exported invoiceQueue is what other modules will use to add jobs.
- Save src/modules/jobs/queues.ts.
- You won't see a change yet. The next chunk creates the worker that processes jobs from this queue.
- Create the src/modules/jobs/workers/ directory by running:
mkdir -p src/modules/jobs/workers
- Create a new file at src/modules/jobs/workers/invoice.worker.ts and add the following code:
import { Worker } from 'bullmq';
import { redisConnection } from '../../../lib/redis.js';
import { prisma } from '../../../lib/prisma.js';
// Worker that processes jobs from the 'invoice-processing' queue
export const invoiceWorker = new Worker(
'invoice-processing',
async (job) => {
const { invoiceId } = job.data;
console.log(`[Worker] Processing invoice ${invoiceId}`);
// Fetch the invoice with its line items from the database
const invoice = await prisma.invoice.findUnique({
where: { id: invoiceId },
include: { lineItems: true },
});
if (!invoice) throw new Error(`Invoice ${invoiceId} not found`);
// Calculate the total from all line items
const total = invoice.lineItems.reduce(
(sum, item) => sum + item.quantity * item.unitPrice,
0
);
console.log(`[Worker] Invoice ${invoiceId} total: ${total}`);
// Update the invoice status from DRAFT to PENDING
await prisma.invoice.update({
where: { id: invoiceId },
data: { status: 'PENDING' },
});
console.log(`[Worker] Invoice ${invoiceId} status updated to PENDING`);
},
{
connection: redisConnection,
}
);
What does this code do?
The worker listens to the invoice-processing queue and runs the processor function for each job it picks up.
- It extracts the invoiceId from the job payload, then fetches the invoice with its line items from the database.
- It calculates the total by multiplying each line item's quantity by its unit price and summing the results.
- Finally, it updates the invoice status from DRAFT to PENDING, signaling that processing is complete.
- Save src/modules/jobs/workers/invoice.worker.ts.
- You won't see a change yet. The worker needs to be imported at server startup, which you will do after wiring up the queue events.
Seeing a module error?
Make sure the src/modules/jobs/workers/ directory exists. If you skipped the mkdir command, create it now before proceeding.
Help me troubleshoot a module resolution error in my worker file
✔️ Awesome, I've got everything!
Great. Double check you have saved both files.
ⓧ I'd like to double check the full code
Here is the full src/modules/jobs/workers/invoice.worker.ts file:
import { Worker } from 'bullmq';
import { redisConnection } from '../../../lib/redis.js';
import { prisma } from '../../../lib/prisma.js';
export const invoiceWorker = new Worker(
'invoice-processing',
async (job) => {
const { invoiceId } = job.data;
console.log(`[Worker] Processing invoice ${invoiceId}`);
const invoice = await prisma.invoice.findUnique({
where: { id: invoiceId },
include: { lineItems: true },
});
if (!invoice) throw new Error(`Invoice ${invoiceId} not found`);
const total = invoice.lineItems.reduce(
(sum, item) => sum + item.quantity * item.unitPrice,
0
);
console.log(`[Worker] Invoice ${invoiceId} total: ${total}`);
await prisma.invoice.update({
where: { id: invoiceId },
data: { status: 'PENDING' },
});
console.log(`[Worker] Invoice ${invoiceId} status updated to PENDING`);
},
{
connection: redisConnection,
}
);
Add job dispatching to the invoice creation flow
When a new invoice is created, you want the API to immediately add a job to the queue. The worker will then pick it up and process it in the background.
- Open src/modules/invoices/invoices.controller.ts.
- Find the import block at the top of the file. Add this import below the existing ones:
import { invoiceQueue } from '../jobs/queues.js';
- Find the createInvoice function. Replace it with this updated version that dispatches a job after creating the invoice:
export async function createInvoice(req: Request, res: Response, next: NextFunction) {
const invoice = await invoicesService.createInvoice(req.body);
// Dispatch a background job to process this invoice
await invoiceQueue.add(
'process-invoice',
{ invoiceId: invoice.id },
{
attempts: 3,
backoff: { type: 'exponential', delay: 1000 },
delay: 2000,
}
);
res.status(201).json(invoice);
}
What does this code do?
- After the invoice is saved to the database, invoiceQueue.add() creates a new job with the invoice ID as its payload.
- The attempts: 3 option tells BullMQ to retry the job up to 3 times if the worker throws an error.
- The backoff: { type: 'exponential', delay: 1000 } option spaces out retries. The first retry waits 1 second, the second waits 2 seconds, and the third waits 4 seconds.
- The delay: 2000 option adds a 2-second delay before the worker picks up the job. This simulates real-world async processing where jobs are not handled instantly.
- Save src/modules/invoices/invoices.controller.ts.
- You won't see a change yet. The worker is not imported at startup, so no jobs will be processed until the final substep.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file.
ⓧ I'd like to double check the full code
Here is the full src/modules/invoices/invoices.controller.ts file:
import { Request, Response, NextFunction } from 'express';
import * as invoicesService from './invoices.service.js';
import { InvoiceStatus } from '../../../generated/prisma/index.js';
import { invoiceQueue } from '../jobs/queues.js';
export async function createInvoice(req: Request, res: Response, next: NextFunction) {
const invoice = await invoicesService.createInvoice(req.body);
await invoiceQueue.add(
'process-invoice',
{ invoiceId: invoice.id },
{
attempts: 3,
backoff: { type: 'exponential', delay: 1000 },
delay: 2000,
}
);
res.status(201).json(invoice);
}
export async function getInvoice(req: Request, res: Response, next: NextFunction) {
const invoice = await invoicesService.getInvoiceById(req.params.id);
res.json(invoice);
}
export async function listInvoices(req: Request, res: Response, next: NextFunction) {
const status = req.query.status as InvoiceStatus | undefined;
const invoices = await invoicesService.listInvoices(status);
res.json(invoices);
}
Add job event listeners and start the worker
BullMQ provides a QueueEvents class that listens to lifecycle events across all workers for a given queue. You will use it to log when jobs complete, fail, or exhaust all retries.
- Open src/modules/jobs/queues.ts and replace the entire file contents with the following:
import { Queue, QueueEvents } from 'bullmq';
import { redisConnection } from '../../lib/redis.js';
import { prisma } from '../../lib/prisma.js';
// Create the invoice processing queue
export const invoiceQueue = new Queue('invoice-processing', {
connection: redisConnection,
});
// Listen to queue-wide lifecycle events via Redis streams
const queueEvents = new QueueEvents('invoice-processing', {
connection: redisConnection,
});
queueEvents.on('completed', ({ jobId }) => {
console.log(`[QueueEvents] Job ${jobId} completed`);
});
queueEvents.on('failed', async ({ jobId, failedReason }) => {
console.error(`[QueueEvents] Job ${jobId} failed: ${failedReason}`);
// If all retries are exhausted, mark the invoice as OVERDUE
const job = await invoiceQueue.getJob(jobId);
if (job && job.attemptsMade >= 3) {
await prisma.invoice.update({
where: { id: job.data.invoiceId },
data: { status: 'OVERDUE' },
});
}
});
queueEvents.on('retries-exhausted', ({ jobId }) => {
console.warn(`[QueueEvents] Job ${jobId} retries exhausted`);
});
What do the event listeners do?
- The completed listener logs a message every time a job finishes successfully.
- The failed listener logs the failure reason. If the job has exhausted all 3 attempts, it updates the invoice status to OVERDUE in the database.
- The retries-exhausted listener logs a warning when BullMQ has no more retry attempts left for a job.
Why QueueEvents instead of Worker events?
Worker-level events only fire for that specific worker instance. QueueEvents uses Redis streams to capture events from all workers processing a given queue. This gives you a single place to monitor the entire queue regardless of how many workers you scale to.
- Save src/modules/jobs/queues.ts.
The last piece is importing the worker and queue events in your server entry point so they start when the application boots.
- Open src/server.ts and replace it with the following:
import app from './app.js';
import { env } from './config/env.js';
// Start the worker and queue event listeners on boot
import './modules/jobs/workers/invoice.worker.js';
import './modules/jobs/queues.js';
app.listen(env.PORT, () => {
console.log(`Server running on port ${env.PORT}`);
});
What do the bare imports do?
Importing a module without assigning it to a variable runs its top-level code. This starts the worker (which begins polling Redis for jobs) and registers the QueueEvents listeners.
- Save src/server.ts.
- Restart the dev server by running:
npx tsx src/server.ts
- You should see Server running on port 3000 in the terminal.
- Open a second terminal and find a client ID by running:
curl http://localhost:3000/api/clients
- Copy one of the id values from the JSON response.
- Create an invoice to test the full flow by running this command, replacing the client ID placeholder with the value you copied:
curl -X POST http://localhost:3000/api/invoices \
-H "Content-Type: application/json" \
-d '{"clientId": "[[CLIENT_ID="your client ID from the list"]]", "dueDate": "2026-07-01", "lineItems": [{"description": "Web Development", "quantity": 10, "unitPrice": 150}]}'
- After about 2 seconds, check the terminal running your server. You should see log lines like:
[Worker] Processing invoice clxxxxxxxxxxxxxxxxxx
[Worker] Invoice clxxxxxxxxxxxxxxxxxx total: 1500
[Worker] Invoice clxxxxxxxxxxxxxxxxxx status updated to PENDING
[QueueEvents] Job 1 completed
- Verify the database was updated by running:
curl http://localhost:3000/api/invoices?status=PENDING
- You should see your invoice returned with "status": "PENDING" in the JSON response.
Not seeing worker logs?
- Make sure Redis is running. Run redis-cli ping in a separate terminal. You should see PONG.
- Check that you saved both src/server.ts and src/modules/jobs/queues.ts with the latest changes.
- Confirm your REDIS_URL in .env matches where Redis is running (default is redis://localhost:6379).
Help me debug my BullMQ worker not processing jobs
✔️ Awesome, I've got everything!
Great. Double check you have saved all files.
ⓧ I'd like to double check the full code
Here is the full src/modules/jobs/queues.ts:
import { Queue, QueueEvents } from 'bullmq';
import { redisConnection } from '../../lib/redis.js';
import { prisma } from '../../lib/prisma.js';
export const invoiceQueue = new Queue('invoice-processing', {
connection: redisConnection,
});
const queueEvents = new QueueEvents('invoice-processing', {
connection: redisConnection,
});
queueEvents.on('completed', ({ jobId }) => {
console.log(`[QueueEvents] Job ${jobId} completed`);
});
queueEvents.on('failed', async ({ jobId, failedReason }) => {
console.error(`[QueueEvents] Job ${jobId} failed: ${failedReason}`);
const job = await invoiceQueue.getJob(jobId);
if (job && job.attemptsMade >= 3) {
await prisma.invoice.update({
where: { id: job.data.invoiceId },
data: { status: 'OVERDUE' },
});
}
});
queueEvents.on('retries-exhausted', ({ jobId }) => {
console.warn(`[QueueEvents] Job ${jobId} retries exhausted`);
});
Here is the full src/server.ts:
import app from './app.js';
import { env } from './config/env.js';
import './modules/jobs/workers/invoice.worker.js';
import './modules/jobs/queues.js';
app.listen(env.PORT, () => {
console.log(`Server running on port ${env.PORT}`);
});
Secret mission
Add a Scheduled Overdue Invoice Checker
Your invoice processing system handles individual jobs well. But what about invoices that slip through the cracks? In production, you need automated systems that periodically scan for problems and take action without human intervention.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing monetary costs. However, PostgreSQL and Redis continue running in the background and consume system memory.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Local PostgreSQL database (invoice_api).
- Local Redis server (stores BullMQ queue data).
- Project folder (invoice-api on your Desktop).
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with your invoice API.
- Your PostgreSQL database continues running in the background and stores your seed data and invoices.
- Redis stays active and retains your BullMQ queue data, including scheduled jobs.
- Your invoice-api project folder remains on your Desktop, ready to open whenever you return.
Pause - I'll come back to this later
Shut down running processes to free up system memory. Your data stays intact so you can resume later.
- Stop the Express dev server by pressing Ctrl+C in the terminal where it is running.
- Stop Redis by running:
redis-cli shutdown
- Stop PostgreSQL by running:
brew services stop postgresql
When you're ready to come back, restart both services:
brew services start postgresql
redis-server
Then start your dev server again with npm run dev from the invoice-api folder.
Delete - I don't want to use this again
Remove all project resources and start fresh. This permanently deletes your database, queue data, and project files.
- Stop the Express dev server by pressing Ctrl+C in the terminal where it is running.
- Drop the PostgreSQL database by running:
psql -c "DROP DATABASE invoice_api"
- Flush all Redis data and shut down the server by running:
redis-cli FLUSHALL
redis-cli shutdown
- Remove the project folder from your Desktop by running:
cd ~/Desktop
rm -rf invoice-api
What about PostgreSQL and Redis themselves?
The commands above delete your project data but leave PostgreSQL and Redis installed on your machine. These are general-purpose tools you will likely use in future projects.
If you installed them via Homebrew and want to fully remove them, you can run brew uninstall postgresql and brew uninstall redis.
Nice Work!
Nice Work!
You just built a production-style Invoice Processing API with background job queues, relational data modeling, and centralized error handling.
Here's what you learned:
- Built a REST API with Express 5 and TypeScript using a feature-based monolith structure that mirrors how mid-level engineers organize real codebases.
- Designed a relational database schema with Prisma ORM including Client, Invoice, and LineItem models with proper one-to-many relations.
- Implemented background job processing with BullMQ including retry logic, exponential backoff, delayed jobs, and job event tracking.
- Secret Mission: Created a scheduled repeatable job system for automated overdue invoice detection and notification queuing.
Ready to quiz yourself?