You’ve built the User model a hundred times. Email, password, username, three validation rules, a hash step, an insert. Your fingers know the shape of it before your brain does, which is exactly why writing it by hand for the hundredth time feels like a waste of a Tuesday afternoon. So you point OpenCode at a local Qwen3.5 9B model and ask it to write the registration endpoint — then you sit there wondering if a 9-billion-parameter model running on your own GPU is actually going to remember to hash the password, or if you’re about to commit a bcrypt-shaped hole straight into version control.
That doubt is reasonable. Backend code doesn’t get the benefit of a visual sanity check the way a broken React component does — a SQL injection vulnerability doesn’t throw an error in the browser, it just sits there until someone finds it. The good news: Qwen3.5 9B handles standard CRUD logic well. The catch is that “well” only holds if you ask for security explicitly. Say “make it secure” and you’ll get basic CORS and nothing else. Name the pieces — Helmet, rate limiting, Zod, Bcrypt — and the model delivers all of them, correctly wired.
This is the pattern for scaffolding an Express.js API with a local model: set up the engine and security middleware first, generate your schema before your logic, and keep every prompt scoped to one file so the model never has to hold more context than it can reliably reason about.
Scaffolding the Express Engine
Initializing the Project
Qwen3.5 9B natively supports a context window up to 262,144 tokens, which sounds like more than enough to hold your entire codebase — and for reading context, it is. But generation quality is a different budget than context capacity. A model that can ingest 200K tokens without losing track of an early instruction can still produce degraded output if you ask it to generate a 20-endpoint API in one response. Treat the context window as room to remember your architectural rules, not a license to ask for everything at once.
Start with the dependency list, generated as a straightforward shell command rather than typed out file-by-file:
npm init -y
npm install express cors helmet express-rate-limit dotenv
npm install prisma @prisma/client bcrypt zod
npm install --save-dev nodemon
Every package in that list earns its place: helmet and cors for headers and origin restriction, express-rate-limit for brute-force protection, prisma and bcrypt for data and password handling, zod for input validation. You’re not adding these later — you’re establishing them as the project’s spine before a single route exists.
Middleware and Security
Here’s where “make it secure” fails you. Prompt Qwen3.5 9B with that phrase alone and it’ll add app.use(cors()) with no origin restriction and call it done — technically true, practically useless. Instead, name every piece:
“Initialize an Express server using Helmet for security headers, CORS restricted to the origin in
process.env.CLIENT_URL, and express-rate-limit capped at 100 requests per 15 minutes per IP.”
That gets you the real thing:
// server.js
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
require('dotenv').config();
const app = express();
// Helmet sets 15+ HTTP response headers (HSTS, X-Content-Type-Options, etc.)
app.use(helmet());
// Restrict cross-origin requests to your known frontend
app.use(cors({ origin: process.env.CLIENT_URL }));
app.use(express.json());
// Baseline brute-force protection: 100 requests per IP per 15 minutes
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100
});
app.use('/api/', limiter);
module.exports = app;
Notice there’s no connection string, no JWT secret, nothing hardcoded — process.env.CLIENT_URL is doing the work. That’s not incidental. Never let the model write a secret directly into server.js. If you ask for a database URL and it doesn’t reach for process.env, reject the output and ask again. Create a .env file at the root of your project to store these values:
CLIENT_URL=http://localhost:3000
DATABASE_URL="postgresql://user:password@localhost:5432/mydb?schema=public"
Generating CRUD Operations
Defining the Data Schema First
Generate the Prisma schema in its own prompt, before you ask for a single line of route logic:
// schema.prisma
model User {
id String @id @default(uuid())
email String @unique
username String @unique
password String
createdAt DateTime @default(now())
}
This isn’t just organizational tidiness. Feeding that schema back into the next prompt — “generate the controller for this User model” — is what keeps the model from hallucinating a field that doesn’t exist three files later. Skip this step and ask for the schema and the controller in the same breath, and you’ll get a controller referencing user.fullName when the schema only defines username.
Building the Controllers
Keep controllers in their own directory, separate from routes, and say so directly in the prompt — it’s a natural boundary for the model to reason within, and it mirrors how you’d structure the project by hand anyway. Every async controller needs explicit error handling, and this is worth stating outright rather than assuming: “Wrap all async controller functions in a try-catch block and return a 500 status on internal errors.” Without that instruction, Qwen3.5 9B will sometimes generate an async route handler that never calls next(error) in its catch block — the request just hangs until the client times out, with nothing in your logs to explain why.
Practical Example: Generating a User Registration Endpoint
Three prompts, three files, one working endpoint.
Step 1 — feed the schema, ask for validation. With the schema.prisma User model already generated, prompt: “Using this User model, write a Zod schema that validates email format, requires a password between 8 and 64 characters, and a username between 3 and 30 characters.”
// validators/registerSchema.js
const { z } = require('zod');
const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(8).max(64),
username: z.string().min(3).max(30)
});
module.exports = { registerSchema };
Step 2 — generate the controller with Bcrypt. Prompt: “Write an Express controller function registerUser that validates the request body against registerSchema, hashes the password with bcrypt using 12 salt rounds, and creates a User record with Prisma. Wrap it in a try-catch and return a 500 on internal errors. Include all required imports at the top of the file.”
That last sentence isn’t filler — small models occasionally drop a require('bcrypt') when a function runs long, and asking explicitly closes that gap more reliably than hoping.
// controllers/userController.js
const bcrypt = require('bcrypt');
const { PrismaClient } = require('@prisma/client');
const { registerSchema } = require('../validators/registerSchema');
// Note: For larger applications, export PrismaClient from a single module
// to prevent connection pool exhaustion across multiple controllers.
const prisma = new PrismaClient();
async function registerUser(req, res) {
try {
const parsed = registerSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const { email, username, password } = parsed.data;
const existingUser = await prisma.user.findFirst({
where: {
OR: [
{ email },
{ username }
]
}
});
if (existingUser) {
return res.status(409).json({ error: 'Email or username already registered' });
}
const passwordHash = await bcrypt.hash(password, 12);
const user = await prisma.user.create({
data: { email, username, password: passwordHash }
});
return res.status(201).json({ id: user.id, email: user.email, username: user.username });
} catch (error) {
console.error('registerUser error:', error);
return res.status(500).json({ error: 'Internal server error' });
}
}
module.exports = { registerUser };
Step 3 — wire the route.
// routes/userRoutes.js
const express = require('express');
const { registerUser } = require('../controllers/userController');
const router = express.Router();
router.post('/register', registerUser);
module.exports = router;
Mount it in server.js with app.use('/api/users', require('./routes/userRoutes')) and you have a /api/users/register endpoint that validates input with Zod, hashes passwords at 12 salt rounds, checks for duplicate emails and usernames before insert, and never touches raw SQL — Prisma’s parameterized queries handle sanitization by default. Written entirely by a 9B model running on your own hardware.
Generated User Registration Endpoint Diagram
Visualizes the data flow and validation sequence of the generated User Registration endpoint, from the initial client request through middleware and the controller’s logic layers, ending with the database insertion.

Visual Notes:
- The request first passes through Express middleware (Rate Limiter) before hitting the route logic.
- Zod acts as a strict boundary, preventing invalid data from reaching the database driver.
- The Prisma Read operation checks for uniqueness (email/username) before proceeding to the expensive Bcrypt hashing step.
Best Practices and Tips
Do:
- Review every generated query, even Prisma ones — the ORM prevents injection, but it won’t catch a logic error like querying the wrong table.
- Use Zod (or Joi, if that’s your stack) to strictly type every input boundary. Don’t let
req.bodyreach a controller unvalidated. - Generate the schema before the controller, every time. Feed it back explicitly in the next prompt.
Don’t:
- Let the agent write a database password, JWT secret, or connection string directly into a file. Enforce
process.envfor every credential, no exceptions. - Skip try-catch blocks around async handlers. An unhandled rejection in an Express route hangs the request instead of failing loudly.
- Ask for the server, routes, controllers, and schema in a single prompt. You’ll get truncated output and syntax errors from context degradation — not because the model is incapable, but because you handed it too much to hold at once.
Troubleshooting Common Issues
The agent forgets to import a module.
This shows up most on longer controller functions — bcrypt or zod gets used but never required at the top of the file. Add a standing rule to your prompt or AGENTS.md: “Always include all required module imports at the top of the file.” Cheap to add, and it closes most of these gaps outright.
A route hangs indefinitely on error.
Check whether the generated async handler actually calls next(error) or returns a response inside its catch block. If it doesn’t, the request just sits open until the client gives up. Either prompt explicitly for try-catch-with-response on every async handler, or install express-async-handler and have the agent wrap routes with it.
The model starts hallucinating field names on a large API. Once you’re past a handful of endpoints, Qwen3.5 9B can lose track of exact parameter names if you’re relying on it to “remember” the schema from three prompts ago. Fix: paste the actual Zod schema or Prisma model definition into the prompt every time you ask for a new endpoint’s controller. Don’t assume it’s still holding your first message.
Conclusion
A 9-billion parameter model handles standard REST CRUD work perfectly well — routing, validation, hashing, parameterized database access, all of it, running entirely on hardware you own. The two things that make or break the output are the same two things that make or break a junior engineer’s first sprint: you have to state your security requirements out loud instead of assuming they’re implied, and you have to hand over one well-scoped task at a time instead of a vague mandate to “build the backend.” Do both, and the code that comes back is genuinely production-shaped.
Next steps: test the /api/users/register endpoint with Postman or Bruno against a local database before you trust it, and read on to see how locking these architectural rules into a permanent AGENTS.md keeps every future session — not just this one — from drifting back to bad defaults.
Related Articles in This Series:
- Scaffolding a Modern Frontend with OpenCode and Next.js
- Mastering Context: Configuring AGENTS.md for Web Projects
