vv1.14.0
Main
This guide outlines recommended patterns and practices when working with this boilerplate.
Each action should have a single responsibility:
// ✅ Good: Single responsibility
export default async function postNewEntity(request: container) {
// 1. Validation
// 2. Business logic
// 3. Persistence
// 4. Return result
}
// ❌ Bad: Multiple responsibilities
export default async function handleUserStuff(request: container) {
// Creates user, sends email, updates analytics, logs...
}// ✅ Good: Encapsulated validation
class Email extends ValueObject<string> {
static create(value: string): Email {
const result = z.string().email().safeParse(value);
if (!result.success) throw new Error("Invalid email");
return new Email(result.data);
}
}
// ❌ Bad: Scattered validation
if (!email.includes("@")) throw new Error("Bad email");✅ DO cache:
❌ DON'T cache:
// ✅ Good: Invalidate related caches
await cache.json.del(tag("user", "find*"));
await cache.json.del(tag("user", `find{id}`, { id: userId }));
// ❌ Bad: Forget to invalidate
// User data is now stale in cache!// Short-lived (1-5 minutes): Real-time-ish data
await cache.json.set(key, data, 60 * 5);
// Medium-lived (10-30 minutes): Semi-static data
await cache.json.set(key, data, 60 * 10);
// Long-lived (1-24 hours): Static data
await cache.json.set(key, data, 60 * 60 * 24);// ✅ Good: Prepared statement (compiled once, executed many times)
const prepare = repository
.select()
.from(entity)
.where(eq(entity.id, sql.placeholder("id")))
.prepare("/unique-name");
const result = await prepare.execute({ id });
// ❌ Bad: Dynamic query every time
const result = await repository
.select()
.from(entity)
.where(eq(entity.id, id));// ✅ Good: Use withPagination helper
withPagination(query, page, limit);
// ❌ Bad: Manual offset/limit
query.offset((page - 1) * limit).limit(limit);// ✅ Good: Select specific columns
const { password, ...publicFields } = getTableColumns(user);
const result = await repository.select(publicFields).from(user);
// ❌ Bad: Select everything
const result = await repository.select().from(user);
// Now you have to manually remove password!// ✅ Good: Semantic status codes
if (!validRequest.success) throw request.badRequest(lang, "validation_failed"); // 400
if (!found) throw request.notFound(lang, "user_not_found"); // 404
if (!authorized) throw request.forbidden(lang, "access_denied"); // 403
if (!completed) throw request.conflict(lang, "resource_conflict"); // 409
// ❌ Bad: Generic errors
throw new Error("Something went wrong"); // 500// ✅ Good: Translatable error keys
throw request.badRequest(request.language(), "email_already_exists");
// ❌ Bad: Hardcoded messages
throw new Error("Email already exists");// ✅ Good: Arrange-Act-Assert
it("should return user when valid ID is provided", async () => {
// Arrange
const userId = "123";
mockRepo.getById.mockResolvedValue(mockUser);
// Act
const result = await userService.getById(userId);
// Assert
expect(result).toEqual(mockUser);
});// ✅ Good: Test domain logic
it("should hash password before storing", async () => {
const plainPassword = "secret123";
await createUser({ password: plainPassword });
expect(hashFunction).toHaveBeenCalledWith(plainPassword);
});
// ❌ Bad: Test Drizzle ORM (already tested by library)
it("should insert into database", async () => {
// Testing library behavior, not your code
});// ✅ Good: Sanitize logs
console.log("User login attempt", { email: user.email });
// ❌ Bad: Logs passwords!
console.log("User data", user); // { email, password, ... }Never return internal error messages or stack traces directly to the client. Always log the detailed error internally and return a generic, safe message.
// ✅ Good: Generic message to user, detailed log internally
logger.error(error);
return reply.send({ message: "An unknown error occurred" });
// ❌ Bad: Leaking internal details
return reply.send({ message: error.message }); // Might contain DB credentials or file paths!// ✅ Good: Validate with Zod
const valid = await schema.actions.create.safeParseAsync(request.body());
// ❌ Bad: Trust user input
const data = request.body();
await repository.insert(entity).values(data); // SQL injection risk!Avoid hardcoding default values for infrastructure parameters in the source code. If a value is environment-dependent, it should be defined exclusively in the .env or system environment.
// ✅ Good: Environment-based config (Single Source of Truth)
const apiKey = process.env.API_KEY;
// ❌ Bad: Hardcoded fallbacks or secrets
const apiKey = process.env.API_KEY || "default_value";
const secret = "sk_live_abc123";This ensures that the application configuration is strictly coupled to the environment, preventing surprise behaviors in different stages (Dev, Test, Prod).
// ✅ Good: Single query with join
const posts = await repository
.select()
.from(post)
.leftJoin(user, eq(post.authorId, user.id));
// ❌ Bad: N+1 queries
const posts = await repository.select().from(post);
for (const post of posts) {
post.author = await repository.select().from(user).where(eq(user.id, post.authorId));
}// ✅ Good: Index frequently queried columns
pgIndex("user", table, ["email", "createdAt"])
// ❌ Bad: No indexes on searchable fields
// Queries will be slow on large tables// ✅ Good: Route delegates to action
api.post("/", request.restricted(postNewEntity));
// ❌ Bad: Business logic in route
api.post("/", async (req, reply) => {
const data = req.body;
const hashed = hash(data.password);
const result = await db.insert(...);
// 50 more lines...
});// ✅ Good: Consistent naming
// Actions: verbNoun (getById, postNewEntity, putUpdateEntity)
// Files: kebab-case (get-by-id.ts, post-new-entity.ts)
// Types: PascalCase (User, PostEntity)
// ❌ Bad: Inconsistent
// fetchUser, createNewPost, UpdateExistingCategory// ✅ Good: Same environment everywhere
docker-compose up -d
// ❌ Bad: "Works on my machine"
npm install && npm start# ✅ Development
NODE_ENV=development bun dev
# ✅ Production
NODE_ENV=production bun start
# ❌ Bad: Unclear environment
bun startBefore shipping to production:
.env.exemple