The patterns used in this codebase. Use them consistently. Don't invent new ones without documenting them here.
Functions that can fail return Result<T, E>. No exceptions for expected failures.
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };Constructors:
import { ok, err } from "@/lib/result";
return ok(user); // Success
return err({ type: "NOT_FOUND" }); // Domain errorInfrastructure errors are system failures — database down, network timeout, external service unavailable. Use tryInfra to catch these:
import { tryInfra } from "@/lib/infra";
async function findById(id: string) {
return tryInfra(() => db.query.posts.findFirst({ where: eq(posts.id, id) }));
}
// Returns Result<Post | undefined, InfrastructureError>Domain errors are expected failures — not found, validation failed, conflict. Define them as discriminated unions:
type NotFound = { type: "NOT_FOUND" };
type AlreadyExists = { type: "ALREADY_EXISTS"; email: string };
type PostError = NotFound | AlreadyExists | InfrastructureError;Use match for exhaustive handling at boundaries:
import { match } from "@/lib/result";
return match(result, {
ok: (post) => jsonSuccess(c, post),
err: (e) => {
switch (e.type) {
case "NOT_FOUND":
return jsonError(c, "NOT_FOUND", "Post not found", 404);
case "INFRASTRUCTURE_ERROR":
return jsonError(c, "INTERNAL_ERROR", "Service unavailable", 500);
}
},
});Use andThen for chaining operations:
import { andThenAsync } from "@/lib/result";
const result = await andThenAsync(
await findUser(userId),
(user) => createPost({ authorId: user.id, ...input })
);Start flat. Add structure when complexity demands it.
modules/posts/
index.ts # Routes + handlers
posts.test.ts # Tests
// modules/posts/index.ts
import { Hono } from "hono";
import { jsonSuccess } from "@/lib/response";
const posts = new Hono();
posts.get("/posts", async (c) => {
const posts = await db.query.posts.findMany();
return jsonSuccess(c, posts);
});
export default posts;When data access logic gets complex, extract it:
modules/posts/
index.ts
posts.repository.ts
posts.test.ts
// modules/posts/posts.repository.ts
import { tryInfra } from "@/lib/infra";
import { db, posts } from "@/db";
import { eq } from "drizzle-orm";
export async function findById(id: string) {
return tryInfra(() =>
db.query.posts.findFirst({ where: eq(posts.id, id) })
);
}
export async function create(data: NewPost) {
return tryInfra(async () => {
const [post] = await db.insert(posts).values(data).returning();
return post;
});
}When business logic needs isolation (rare in a lite template), add use cases:
modules/posts/
index.ts
posts.repository.ts
posts.usecases.ts
posts.errors.ts
posts.test.ts
Only add this structure when you have logic worth testing in isolation.
Use Zod with @hono/zod-validator:
import { z } from "zod";
import { zValidator } from "@hono/zod-validator";
const createPostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string(),
published: z.boolean().default(false),
});
posts.post("/posts", zValidator("json", createPostSchema), async (c) => {
const input = c.req.valid("json");
// input is typed: { title: string; content: string; published: boolean }
});For query params:
const listSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
});
posts.get("/posts", zValidator("query", listSchema), async (c) => {
const { page, limit } = c.req.valid("query");
});All responses follow a consistent shape:
// Success
{ success: true, data: T }
// Error
{ success: false, error: { code: string, message: string, details?: object } }Use helpers from @/lib/response:
import { jsonSuccess, jsonError, HttpStatus } from "@/lib/response";
// Success
return jsonSuccess(c, post);
return jsonSuccess(c, post, HttpStatus.CREATED);
// Error
return jsonError(c, "NOT_FOUND", "Post not found", HttpStatus.NOT_FOUND);
return jsonError(c, "VALIDATION_ERROR", "Invalid input", HttpStatus.BAD_REQUEST, {
fields: { title: "Required" }
});Use Hono's built-in app.request() for integration tests:
import { describe, it, expect } from "vitest";
import app from "@/app";
describe("Posts", () => {
it("GET /posts returns empty list", async () => {
const res = await app.request("/posts");
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual({
success: true,
data: [],
});
});
it("POST /posts creates a post", async () => {
const res = await app.request("/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "Test", content: "Content" }),
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.success).toBe(true);
expect(body.data.title).toBe("Test");
});
});For testing with database, use a test database and clean up between tests.
// src/db/schema.ts
import { pgTable, text, timestamp, uuid, boolean } from "drizzle-orm/pg-core";
export const posts = pgTable("posts", {
id: uuid("id").primaryKey().defaultRandom(),
title: text("title").notNull(),
content: text("content").notNull(),
published: boolean("published").notNull().default(false),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});
export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;import { db, posts } from "@/db";
import { eq, desc } from "drizzle-orm";
// Find one
const post = await db.query.posts.findFirst({
where: eq(posts.id, id),
});
// Find many
const allPosts = await db.query.posts.findMany({
orderBy: desc(posts.createdAt),
limit: 20,
});
// Insert
const [newPost] = await db.insert(posts).values(data).returning();
// Update
const [updated] = await db
.update(posts)
.set({ title: "New Title", updatedAt: new Date() })
.where(eq(posts.id, id))
.returning();
// Delete
await db.delete(posts).where(eq(posts.id, id));Always wrap in tryInfra when used in repositories.
Use .http files for manual API testing. Works in terminal and VSCode.
requests/
├── _base.http # Shared variables
├── health.http # Health endpoints
└── posts.http # Module endpoints (auto-generated)
Define in requests/_base.http:
@base = http://localhost:3000
@contentType = application/json
@id = 550e8400-e29b-41d4-a716-446655440000Use with {{variable}} syntax:
GET {{base}}/posts/{{id}}### Request Name
### Optional description
METHOD {{base}}/path
Header-Name: value
{
"json": "body"
}Example:
### Create Post
POST {{base}}/posts
Content-Type: {{contentType}}
{
"title": "Hello World",
"content": "This is my first post"
}
### Get Post by ID
GET {{base}}/posts/{{id}}
### Delete Post
DELETE {{base}}/posts/{{id}}Terminal:
pnpm http list # List all .http files
pnpm http health # Run all requests in health.http
pnpm http health ping # Run requests matching "ping"
pnpm http posts create # Run "create" request from posts.httpVSCode:
- Install REST Client extension
- Open any
.httpfile - Click "Send Request" above any request
Terminal output includes:
- Request name
- Method and URL
- HTTP status (color-coded)
- Response body (JSON pretty-printed if
jqis installed)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Create Post
POST http://localhost:3000/posts
HTTP 201
{
"success": true,
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"title": "Hello World"
}
}
The module scaffolder generates .http files automatically:
pnpm new:module posts
# Creates requests/posts.http with CRUD requests- Keep
_base.httpfor shared config — it's loaded automatically - Name requests clearly — names are used for filtering
- Store test IDs as variables for reuse across requests
- Use separate files per module to keep things organized