Complete Request Flow#
See how a complete request flows through: Auth → Validation → Controller → Database → Response
Full Example: List Users Endpoint#
1. Define Schema#
// schemas/users.ts
import { z } from "zod";
export const userSchemas = {
list: {
query: z.object({
limit: z.coerce.number().min(1).max(100).default(20),
offset: z.coerce.number().min(0).default(0),
}),
},
};
2. Create Database Function#
// db/users.ts
import { db } from "./index.js";
import { usersTable } from "./schema.js";
export async function listUsers(limit: number, offset: number) {
return await db
.select()
.from(usersTable)
.limit(limit)
.offset(offset);
}
3. Define Route with Middleware#
// routes/users.ts
import { Router } from "express";
import type { Request, Response, NextFunction } from "express";
import { requireAdmin } from "../middleware/auth.js";
import { validateSchema, getValidatedData } from "../middleware/validation.js";
import { userSchemas } from "../schemas/users.js";
import { listUsers } from "../db/users.js";
const router = Router();
router.get(
"/",
requireAdmin, // 1️⃣ Check authentication & role
validateSchema(userSchemas.list), // 2️⃣ Validate query params
async (req: Request, res: Response, next: NextFunction) => {
try {
// 3️⃣ Get validated data (type-safe)
const { query } = getValidatedData(req, userSchemas.list);
// 4️⃣ Call database function
const users = await listUsers(query.limit, query.offset);
// 5️⃣ Return response
return res.json({ users });
} catch (err) {
next(err); // 6️⃣ Pass errors to error handler
}
}
);
export { router as usersRouter };
4. Register Router#
// server.ts
import { usersRouter } from "./routes/users.js";
app.use("/api/users", usersRouter);
Request Flow Diagram#
┌─────────────────────────────────────────────────────────────┐
│ Client Request │
│ GET /api/users?limit=10&offset=0 │
│ Cookie: better-auth.session_token=abc123 │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 1️⃣ CORS Middleware │
│ ✓ Check if origin is allowed │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 2️⃣ Morgan Logger │
│ → Log incoming request │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 3️⃣ requireAdmin (Auth Middleware) │
│ ✓ Get session from Better Auth │
│ ✓ Check if user has ADMIN role │
│ ✓ Attach req.authSession and req.requestorId │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 4️⃣ validateSchema (Validation Middleware) │
│ ✓ Validate req.query.limit and req.query.offset │
│ ✓ Attach req.validated.query │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 5️⃣ Route Handler (Controller) │
│ → const { query } = getValidatedData(req, schema) │
│ → const users = await listUsers(query.limit, query.offset) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 6️⃣ Database Query (Drizzle ORM) │
│ → db.select().from(usersTable).limit(10).offset(0) │
│ ← Returns array of users │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 7️⃣ Response │
│ ← 200 OK │
│ ← { "users": [...] } │
└─────────────────────────────────────────────────────────────┘
Complete CRUD Example#
POST /api/users (Create User)#
// Schema
const createUserSchema = {
body: z.object({
name: z.string().min(2),
email: z.string().email(),
password: z.string().min(8),
}),
};
// Database function
async function createUser(data: { name: string; email: string; password: string }) {
return await db.insert(usersTable).values(data).returning();
}
// Route
router.post(
"/",
validateSchema(createUserSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { body } = getValidatedData(req, createUserSchema);
const user = await createUser(body);
res.status(StatusCodes.CREATED).json({ user });
} catch (err) {
next(err);
}
}
);
Flow:
1. Client sends POST with JSON body
2. Validation middleware validates req.body
3. Controller gets typed data
4. Database inserts user
5. Returns 201 Created with user object
GET /api/users/:id (Get User by ID)#
// Schema
const getUserSchema = {
params: z.object({
id: z.string().uuid(),
}),
};
// Database function
async function getUserById(id: string) {
return await db
.select()
.from(usersTable)
.where(eq(usersTable.id, id))
.limit(1);
}
// Route
router.get(
"/:id",
requireAuth(),
validateSchema(getUserSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { params } = getValidatedData(req, getUserSchema);
const user = await getUserById(params.id);
if (!user) {
return res.status(StatusCodes.NOT_FOUND).json({
error: "User not found",
});
}
res.json({ user });
} catch (err) {
next(err);
}
}
);
Flow: 1. Auth middleware checks session 2. Validation validates UUID format 3. Database queries by ID 4. Returns user or 404
PUT /api/users/:id (Update User)#
// Schema
const updateUserSchema = {
params: z.object({
id: z.string().uuid(),
}),
body: z.object({
name: z.string().min(2).optional(),
email: z.string().email().optional(),
}),
};
// Database function
async function updateUser(id: string, data: Partial<User>) {
return await db
.update(usersTable)
.set(data)
.where(eq(usersTable.id, id))
.returning();
}
// Route
router.put(
"/:id",
requireAuth(),
validateSchema(updateUserSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { params, body } = getValidatedData(req, updateUserSchema);
// Check ownership (user can only update their own profile)
if (params.id !== req.requestorId) {
return res.status(StatusCodes.FORBIDDEN).json({
error: "You can only update your own profile",
});
}
const user = await updateUser(params.id, body);
res.json({ user });
} catch (err) {
next(err);
}
}
);
Flow: 1. Auth middleware validates session 2. Validation validates params + body 3. Controller checks resource ownership 4. Database updates user 5. Returns updated user
DELETE /api/users/:id (Delete User)#
// Schema
const deleteUserSchema = {
params: z.object({
id: z.string().uuid(),
}),
};
// Database function
async function deleteUser(id: string) {
return await db
.delete(usersTable)
.where(eq(usersTable.id, id))
.returning();
}
// Route
router.delete(
"/:id",
requireAdmin,
validateSchema(deleteUserSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { params } = getValidatedData(req, deleteUserSchema);
await deleteUser(params.id);
res.status(StatusCodes.NO_CONTENT).send();
} catch (err) {
next(err);
}
}
);
Flow: 1. Auth middleware checks admin role 2. Validation validates UUID 3. Database deletes user 4. Returns 204 No Content
Middleware Order Example#
router.get(
"/users",
requireAdmin, // 1️⃣ Must be admin
validateSchema(schema), // 2️⃣ Validate after auth (so we know who's requesting)
controller // 3️⃣ Execute business logic
);
// OR
router.post(
"/public-contact",
validateSchema(schema), // 1️⃣ Validate first (no auth needed)
controller // 2️⃣ Process contact form
);
Error Handling in Flow#
router.get("/users",
requireAdmin,
validateSchema(schema),
async (req, res, next) => {
try {
const users = await getUsers();
res.json({ users });
} catch (err) {
next(err); // ← Pass to global error handler
}
}
);
// Global error handler catches it
app.use((err, req, res, next) => {
logger.error(err.message, { endpoint: req.originalUrl });
res.status(500).json({ error: "Internal server error" });
});
Real-World Example: Create Post#
// Schema
const createPostSchema = {
body: z.object({
title: z.string().min(5).max(200),
content: z.string().min(10),
tags: z.array(z.string()).max(5).optional(),
}),
};
// Route
router.post(
"/posts",
requireAuth(), // Must be logged in
validateSchema(createPostSchema), // Validate title, content, tags
async (req, res, next) => {
try {
const { body } = getValidatedData(req, createPostSchema);
// Create post with author from session
const post = await createPost({
...body,
authorId: req.requestorId, // From auth middleware
createdAt: new Date(),
});
// Log the action
logger.info("Post created", {
postId: post.id,
authorId: req.requestorId,
});
res.status(StatusCodes.CREATED).json({ post });
} catch (err) {
next(err);
}
}
);
Complete Flow:
1. Client sends POST with title, content, tags
2. requireAuth() validates session, sets req.requestorId
3. validateSchema() validates body data
4. Controller creates post with author ID from session
5. Database inserts post
6. Logger logs the action
7. Returns 201 Created with post object
Pagination Example#
const listSchema = {
query: z.object({
limit: z.coerce.number().min(1).max(100).default(20),
offset: z.coerce.number().min(0).default(0),
}),
};
router.get(
"/posts",
validateSchema(listSchema),
async (req, res, next) => {
try {
const { query } = getValidatedData(req, listSchema);
const posts = await db
.select()
.from(postsTable)
.limit(query.limit)
.offset(query.offset);
res.json({ posts, limit: query.limit, offset: query.offset });
} catch (err) {
next(err);
}
}
);
URL: GET /api/posts?limit=10&offset=20
Best Practices#
- ✅ Wrap in try-catch - Always catch errors and pass to
next() - ✅ Use getValidatedData() - Get type-safe validated data
- ✅ Check ownership - Verify user owns resource before modification
- ✅ Log important actions - User creation, deletion, admin actions
- ✅ Return proper status codes - 200, 201, 204, 400, 401, 403, 404, 500
- ✅ Validate before processing - Fail fast with validation errors
- ✅ Use middleware composition - Auth + Validation + Controller
Next Steps#
- Error Handling - Handle errors in the flow
- Drizzle Guide - Database queries
- Zod Guide - Advanced validation patterns