Skip to main content

Command Palette

Search for a command to run...

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

Updated
8 min read
Building a Mini Centralized Exchange (CEX) with Bun, TypeScript, Redis, and PostgreSQL
S

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:

  1. Validate incoming request data using Zod.

  2. Hash the user's password using bcrypt.

  3. Store the user in PostgreSQL.

  4. Generate a JWT token.

  5. 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:

  1. Validate the request body.

  2. Find the user by username.

  3. Verify the password using bcrypt.

  4. Generate a JWT token.

  5. 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.