Building Safe Node.js APIs: Express Structure for AI Agents
AI agents like Claude can write code fast, but Express's minimal structure creates dangerous patterns. Here's how to enforce safety through architecture.
The Express Problem
Express is gloriously unopinionated. That's its strength and its weakness. A blank Express app lets you put logic anywhere: route handlers, middleware, utils, even hardcoded in callbacks.
When an AI agent writes code against this blank slate, it often creates unmaintainable patterns:
- Route handlers as god functions: 500-line handlers mixing HTTP logic, validation, business logic, database calls, and error handling.
- Raw SQL strings: Queries embedded directly in route handlers, vulnerable to injection and duplicated across endpoints.
- Missing validation: Trusting request.body directly, leading to type errors and data integrity issues.
- Silent failures: Catch-all error handlers that swallow important context, making debugging impossible.
- No separation of concerns: No distinction between HTTP layer, business logic, and persistence—everything tangled together.
These patterns emerge not because AI agents are careless, but because Express itself doesn't enforce structure. The solution isn't blaming the agent—it's building guardrails into your codebase.
The Controller → Service → Repository Pattern
Structure prevents mistakes. The three-tier pattern enforces separation of concerns and gives AI agents a clear template to follow:
- Controller (HTTP layer): Only handles request/response, parameter extraction, and delegation.
- Service (business logic): All domain rules, orchestration, and validation live here.
- Repository (persistence layer): All database operations, including queries, transactions, and migrations.
This structure makes it impossible for an AI agent to accidentally put SQL in a route handler—there's literally nowhere to put it without violating the obvious pattern.
Example 1: The Three-Layer Structure
Here's what a well-structured endpoint looks like:
// src/controllers/UserController.ts
import { Request, Response } from "express";
import { UserService } from "../services/UserService";
import { AppError } from "../errors/AppError";
export const createUser = async (req: Request, res: Response) => {
try {
// Controller only: extract, validate input structure, delegate
const { email, name } = req.body;
const user = await UserService.create({ email, name });
res.status(201).json(user);
} catch (error) {
// Global error handler catches this
throw error;
}
};
// src/services/UserService.ts
import { UserRepository } from "../repositories/UserRepository";
import { AppError } from "../errors/AppError";
export const UserService = {
async create(data: { email: string; name: string }) {
// Service: business rules, validation, orchestration
if (!data.email.includes("@")) {
throw new AppError("Invalid email", 400);
}
const existing = await UserRepository.findByEmail(data.email);
if (existing) {
throw new AppError("Email already exists", 409);
}
return UserRepository.create(data);
},
};
// src/repositories/UserRepository.ts
import { db } from "../db";
export const UserRepository = {
async create(data: { email: string; name: string }) {
// Repository: only database operations
const [user] = await db
.insert(users)
.values(data)
.returning();
return user;
},
async findByEmail(email: string) {
return db.query.users.findFirst({
where: (users, { eq }) => eq(users.email, email),
});
},
};Notice: The controller is minimal. The service owns business logic. The repository owns data access. No SQL in controllers, no business logic in handlers.
Enforcing Validation with Zod Middleware
Raw request validation errors crash servers. Instead, enforce validation as middleware that catches bad data before it reaches controllers.
// src/middleware/validate.ts
import { Request, Response, NextFunction } from "express";
import { ZodSchema } from "zod";
import { AppError } from "../errors/AppError";
export const validateBody =
(schema: ZodSchema) =>
async (req: Request, res: Response, next: NextFunction) => {
try {
const validated = await schema.parseAsync(req.body);
req.body = validated; // Replace with validated data
next();
} catch (error) {
// Structured error response
throw new AppError(
"Validation failed",
400,
error instanceof Error ? error.message : "Unknown error"
);
}
};
// src/schemas/UserSchema.ts
import { z } from "zod";
export const createUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(255),
});
// src/routes/users.ts
import { Router } from "express";
import { validateBody } from "../middleware/validate";
import { createUserSchema } from "../schemas/UserSchema";
import { createUser } from "../controllers/UserController";
const router = Router();
router.post(
"/",
validateBody(createUserSchema),
createUser
);
export default router;This middleware prevents AI agents from writing unvalidated handlers. Bad requests fail fast with clear errors.
Get the free Node.js CLAUDE.md template
Enterprise-grade conventions for every major stack, plus Claude Code and prompt engineering guides. No account needed.
Global Error Handling: AppError Pattern
Every error needs context. Create an AppError class that enforces this discipline:
// src/errors/AppError.ts
export class AppError extends Error {
public readonly statusCode: number;
public readonly context?: unknown;
constructor(
message: string,
statusCode: number = 500,
context?: unknown
) {
super(message);
this.name = "AppError";
this.statusCode = statusCode;
this.context = context;
}
}
// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from "express";
import { AppError } from "../errors/AppError";
export const errorHandler = (
error: Error,
req: Request,
res: Response,
next: NextFunction
) => {
const appError = error instanceof AppError
? error
: new AppError("Internal server error", 500, error);
// Log with context for debugging
console.error({
error: appError.message,
status: appError.statusCode,
path: req.path,
context: appError.context,
});
res.status(appError.statusCode).json({
error: appError.message,
...(process.env.NODE_ENV === "development" && {
context: appError.context,
}),
});
};
// src/app.ts
import express from "express";
import { errorHandler } from "./middleware/errorHandler";
const app = express();
app.use(express.json());
app.use("/api/users", userRoutes);
// Global error handler MUST be last
app.use(errorHandler);
export default app;Every error carries status and context. The global handler ensures nothing crashes silently. No surprises in production.
Database Patterns: Transactions, Migrations, Pooling
AI agents often write unsafe database code. Lock them into patterns:
- Connection pooling: Always configured at startup, never per-request.
- Transactions: Built into service methods, never left to manual management.
- Migrations: Versioned, immutable, tracked in version control.
- Type-safe queries: Use an ORM/query builder (Drizzle, TypeORM, Prisma) to prevent SQL injection.
// src/db/index.ts
import { Pool } from "pg";
const pool = new Pool({
max: 20,
connectionTimeoutMillis: 5000,
idleTimeoutMillis: 10000,
});
export const db = {
async transaction<T>(
callback: (client: any) => Promise<T>
): Promise<T> {
const client = await pool.connect();
try {
await client.query("BEGIN");
const result = await callback(client);
await client.query("COMMIT");
return result;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
},
};
// src/services/OrderService.ts
export const OrderService = {
async createWithItems(
order: OrderData,
items: OrderItemData[]
) {
return db.transaction(async (client) => {
// All writes succeed or all fail
const newOrder = await client.query(
"INSERT INTO orders ... RETURNING *"
);
for (const item of items) {
await client.query(
"INSERT INTO order_items ..."
);
}
return newOrder;
});
},
};Environment Validation at Startup
Missing environment variables should crash at boot, not at 2am during a request. Validate before the server starts:
// src/env.ts
import { z } from "zod";
const envSchema = z.object({
NODE_ENV: z.enum(["development", "production"]),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
PORT: z.coerce.number().int().positive().optional().default(3000),
});
export const env = (() => {
try {
return envSchema.parse(process.env);
} catch (error) {
console.error(
"Invalid environment variables:",
error instanceof z.ZodError
? error.flatten().fieldErrors
: error
);
process.exit(1);
}
})();
// src/index.ts
import { env } from "./env";
import app from "./app";
// All env vars validated before server starts
app.listen(env.PORT, () => {
console.log(`Server running on port ${env.PORT}`);
});Common AI Agent Mistakes & How to Prevent Them
Mistake 1: Everything in the Route Handler
Problem: AI stuffs validation, database logic, and error handling into one massive handler.
Prevention: Enforce the three-layer pattern. A controller should never exceed 20 lines. If it does, logic leaked into the wrong layer.
Mistake 2: Raw SQL Queries
Problem: String interpolation creates injection vulnerabilities and makes queries non-reusable.
Prevention: Mandate an ORM or query builder library. Don't give agents raw SQL access. Make repositories the only place queries live.
Mistake 3: Skipped Validation
Problem: AI assumes request.body is safe and types match expected shape.
Prevention: Zod schemas are mandatory. Make validation middleware a required step on every endpoint. If an agent writes an endpoint without validation, code review catches it.
Mistake 4: Silent Error Handling
Problem: Catch-all handlers that log nothing or return generic "500 error" messages.
Prevention: Use AppError class throughout. Every catch block must throw a descriptive error. The global handler ensures nothing silently fails.
Mistake 5: No Connection Management
Problem: Creating new database connections per-request or forgetting to close connections.
Prevention: Initialize a connection pool at startup. Make it a singleton. Document that agents should never create connections manually.
CLAUDE.md sets the rules. Archie runs the workflow.
Persistent memory, role-based skills, and approval gates. From idea to merged PR.
Your AI-Safe Express Checklist
- ✓ Controllers are thin: request/response only, max 20 lines each
- ✓ All business logic lives in services
- ✓ All database access lives in repositories
- ✓ Every endpoint uses Zod validation middleware
- ✓ All errors thrown as AppError with status and context
- ✓ Global error handler is the only catch-all
- ✓ Database connection pool initialized at startup
- ✓ Transactions handled in service methods, never manually
- ✓ All env vars validated before server starts
- ✓ No raw SQL strings anywhere in the codebase
Ready to structure your codebase for AI?
These patterns work best when documented in your codebase. Create a CLAUDE.md file with your project's conventions, architecture, and patterns. AI agents follow structure when it's clear and enforced.