Building a Mini Centralized Exchange (CEX) with Bun, TypeScript, Redis, and PostgreSQL

I'm Shubham (@shubhamsinghbundela), I'm a Software Engineer, a Full-stack developer, a tech enthusiast, and a technical writer here on @Hashnode. I have a strong zeal to share my acquired knowledge and I am also willing to learn from others.
Architecture Overview
Our Mini Centralized Exchange follows an event-driven architecture:
Frontend / API Client
|
v
Backend API (Express)
|
v
Redis Queue (backend-to-engine-broker)
|
v
Matching Engine Process
|
v
Backend-Specific Response Queue
|
v
Backend API Response
Tech Stack
TypeScript
Bun
Express
Redis
Prisma
PostgreSQL
JWT Authentication
Zod Validation
Step 1: Initialize the Backend Project
Create a new backend directory and initialize a Bun project:
mkdir backend
cd backend
bun init
This generates the basic project structure along with a package.json file.
Step2: Set Up Prisma with PostgreSQL
We'll use Prisma as our ORM and PostgreSQL as the primary database.
Instead of manually configuring everything, follow the official Prisma Bun guide:
https://www.prisma.io/docs/guides/runtimes/bun
The guide covers:
Installing Prisma
Initializing Prisma
Connecting PostgreSQL
Creating your first schema
Running migrations
Generating the Prisma Client
Step3: Install Initial Dependencies
Before writing any code, let's install only the dependencies required to start our Express server and manage environment variables.
bun add express cors dotenv
bun add -d typescript @types/bun @types/express @types/cors
We'll install additional dependencies such as Prisma, PostgreSQL, Redis, JWT, Zod, and bcrypt as we need them throughout the project.
Project Structure
At this stage, our project structure looks like this:
backend/
├── src/
│ ├── index.ts
│ └── utils/
│ └── env.ts
├── .env
├── .gitignore
├── bun.lock
├── package.json
├── prisma.config.ts
├── README.md
└── tsconfig.json
All application code will live inside the src directory.
Step4: Creating the Express Server
Create src/index.ts:
import cors from "cors";
import express, {
type NextFunction,
type Request,
type Response,
} from "express";
import { env } from "./utils/env.js";
const app = express();
app.use(cors());
app.use(express.json());
app.get("/health", async (_req, res) => {
res.json({ ok: true });
});
app.use(
(err: unknown, _req: Request, res: Response, _next: NextFunction) => {
console.error(err);
res.status(500).json({
error: err instanceof Error ? err.message : "internal_server_error",
});
},
);
app.listen(env.port, () => {
console.log(`Backend running on http://localhost:${env.port}`);
});
This gives us:
CORS support for frontend requests
JSON request parsing
A health check endpoint
Global error handling middleware
A configurable server port
Step 5: Managing Environment Variables
Create src/utils/env.ts:
import "dotenv/config";
function readRequiredEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required env variable: ${name}`);
}
return value;
}
export const env = {
port: Number(process.env.PORT ?? "3000"),
};
Loading environment variables through a dedicated module keeps configuration centralized and makes it easier to validate required values as the application grows.
Step 6: Create a .env File
PORT=3000
Step 7: Start the Server
Run the development server:
bun run dev
If everything is configured correctly, you should see:
Backend running on http://localhost:3000
You can verify the server is working by visiting:
http://localhost:3000/health
Expected response:
{
"ok": true
}
Step8: Creating the Signup API
Now that our Express server is running and connected to PostgreSQL through Prisma, let's implement the first authentication endpoint: Signup.
The signup flow will:
Validate incoming request data using Zod.
Hash the user's password using bcrypt.
Store the user in PostgreSQL.
Generate a JWT token.
Return the authenticated user information.
Register the Application Router
First, update src/index.ts and register the application's root router.
import { appRouter } from "./routes/index.js";
app.use(appRouter);
This keeps route definitions separate from server initialization, making the project easier to scale as new features are added.
Create the Root Router
Create src/routes/index.ts:
import { Router } from "express";
import { authRouter } from "./auth-routes.js";
export const appRouter = Router();
appRouter.use(authRouter);
The root router acts as a central place where all feature-specific routers are registered.
Create the Authentication Router
Create src/routes/auth-routes.ts:
import { Router } from "express";
import { signup } from "../controllers/auth-controller.js";
import { asyncHandler } from "../utils/async-handler.js";
export const authRouter = Router();
authRouter.post("/signup", asyncHandler(signup));
Instead of placing business logic directly inside route handlers, we delegate it to a controller.
Create an Async Handler
Express does not automatically catch errors thrown inside async functions. To avoid repetitive try/catch blocks, create a reusable async wrapper.
Create src/utils/async-handler.ts:
import type {
NextFunction,
Request,
RequestHandler,
Response,
} from "express";
export function asyncHandler(
handler: (
req: Request,
res: Response,
next: NextFunction,
) => Promise<void>,
): RequestHandler {
return function wrappedHandler(req, res, next) {
void handler(req, res, next).catch(next);
};
}
Any unhandled error will automatically reach Express's global error middleware.
Define the Request Schema
Before creating users, we should validate incoming data.
Create src/types/auth-schema.ts:
import { z } from "zod";
export const authSchema = z.object({
username: z.string().trim().min(1, "username is required"),
password: z.string().min(1, "password is required"),
});
Using Zod ensures invalid requests never reach our database layer.
Create a Validation Helper
Create src/utils/validation.ts:
import type { Response } from "express";
import type { ZodError } from "zod";
export function sendValidationError(
res: Response,
error: ZodError,
): void {
res.status(400).json({
error: "validation_error",
issues: error.issues.map((issue) => ({
path: issue.path.join("."),
message: issue.message,
})),
});
}
This gives clients a consistent error format whenever validation fails.
Create JWT Utilities
Create src/utils/auth.ts:
import jwt from "jsonwebtoken";
import { env } from "./env.js";
export interface TokenPayload {
userId: string;
}
export function createToken(payload: TokenPayload): string {
return jwt.sign(payload, env.jwtSecret, {
expiresIn: "7d",
});
}
We'll use this helper throughout the application whenever a JWT token needs to be generated.
Create the Signup Controller
Create src/controllers/auth-controller.ts:
import bcrypt from "bcryptjs";
import type { Request, Response } from "express";
import { prisma } from "../db.js";
import { authSchema } from "../types/auth-schema.js";
import { createToken } from "../utils/auth.js";
import { sendValidationError } from "../utils/validation.js";
export async function signup(
req: Request,
res: Response,
): Promise<void> {
const parsedBody = authSchema.safeParse(req.body);
if (!parsedBody.success) {
sendValidationError(res, parsedBody.error);
return;
}
const { username, password } = parsedBody.data;
const hashedPassword = await bcrypt.hash(password, 10);
try {
const user = await prisma.user.create({
data: {
username,
password: hashedPassword,
},
});
res.status(201).json({
token: createToken({
userId: user.id,
}),
userId: user.id,
username: user.username,
});
} catch {
res.status(409).json({
error: "username already exists",
});
}
}
This controller validates the request, hashes the password, creates the user, and immediately returns an authentication token.
Required Environment Variables
Update your .env file:
PORT=3000
JWT_SECRET=super-secret-key
Also expose the secret in src/utils/env.ts:
export const env = {
port: Number(process.env.PORT ?? "3000"),
jwtSecret: readRequiredEnv("JWT_SECRET"),
};
Testing the Endpoint
Send a request to:
POST /signup
Request body:
{
"username": "shubham",
"password": "password123"
}
Successful response:
{
"token": "<jwt-token>",
"userId": "user-id",
"username": "shubham"
}
At this point, users can successfully create accounts and receive a JWT token that can be used for authenticated requests throughout the exchange.
Step9: Creating the Signin API
With user registration complete, let's implement the Signin API.
The signin flow will:
Validate the request body.
Find the user by username.
Verify the password using bcrypt.
Generate a JWT token.
Return the authenticated user's information.
Register the Signin Route
Update src/routes/auth-routes.ts:
import { Router } from "express";
import { signin, signup } from "../controllers/auth-controller.js";
import { asyncHandler } from "../utils/async-handler.js";
export const authRouter = Router();
authRouter.post("/signup", asyncHandler(signup));
authRouter.post("/signin", asyncHandler(signin));
We now expose two authentication endpoints:
POST /signup
POST /signin
Create the Signin Controller
Update src/controllers/auth-controller.ts:
export async function signin(
req: Request,
res: Response,
): Promise<void> {
const parsedBody = authSchema.safeParse(req.body);
if (!parsedBody.success) {
sendValidationError(res, parsedBody.error);
return;
}
const { username, password } = parsedBody.data;
const userExists = await prisma.user.findFirst({
where: {
username,
},
});
if (!userExists) {
res.status(401).json({
error: "username not exists",
});
return;
}
const correctPassword = await bcrypt.compare(
password,
userExists.password,
);
if (!correctPassword) {
res.status(403).json({
error: "password is invalid",
});
return;
}
res.status(201).json({
token: createToken({
userId: userExists.id,
}),
userId: userExists.id,
username: userExists.username,
});
}
How the Signin Flow Works
Client
|
| POST /signin
v
Validate Request (Zod)
|
v
Find User (Prisma)
|
v
Compare Password (bcrypt)
|
v
Generate JWT
|
v
Return Token
Unlike the signup endpoint, we do not create a new user. Instead, we verify the supplied credentials and issue a new JWT token if authentication succeeds.
Testing the Endpoint
Send a request to:
POST /signin
Request body:
{
"username": "shubham",
"password": "password123"
}
Successful response:
{
"token": "<jwt-token>",
"userId": "user-id",
"username": "shubham"
}
If the username does not exist:
{
"error": "username not exists"
}
If the password is incorrect:
{
"error": "password is invalid"
}
At this point, users can register, sign in, and receive JWT tokens that will be used to access protected exchange APIs in the upcoming sections.



