This template assumes most code is written by AI agents. The conventions
exist so bun run check is a reliable signal.
Each feature lives at src/api/<Feature>/:
tickets/
├── tickets.routes.ts # Elysia route group (HTTP only)
├── tickets.service.ts # business logic, Drizzle
├── tickets.schemas.ts # TypeBox request/response shapes
└── tickets.types.ts # types inferred from db schema
Routes never query the DB directly. Services never touch t.*. Import
boundaries are enforced by the resource-architecture and
module-boundaries ESLint plugins.
Add the table to src/clients/postgres/schema/<feature>.schema.ts,
then:
bun run db:generate # creates a new migration from the diff
bun run db:migrate # applies itCommit the generated SQL.
bun run new:resource -- TicketsThis generates the four .ts files with the right import boundaries
already in place. Edit each to fit the domain.
A minimal service:
import { eq } from "drizzle-orm";
import { db } from "../../clients/postgres";
import { tickets } from "../../clients/postgres/schema";
import { AUDIT_ACTIONS, auditLogService } from "../../lib/audit-log";
import { ApiErrors } from "../../lib/errors";
export class TicketsService {
async create(userId: string, name: string) {
const [created] = await db
.insert(tickets)
.values({ userId, name })
.returning();
if (!created) throw ApiErrors.internal("Failed to create ticket");
void auditLogService.record({
userId,
action: AUDIT_ACTIONS.NOTIFICATION_STATUS_UPDATED,
metadata: { ticketId: created.id },
});
return created;
}
}
export const ticketsService = new TicketsService();The audit-log plugin requires a recorded event for every mutating
service method (create*, update*, delete*, ...). The
db-transactions plugin requires db.transaction(async (tx) => …) if
the function performs ≥2 writes.
A minimal routes file:
import { createAuthMiddleware } from "../auth/auth.plugin";
import { CreateTicketSchema, TicketResponse } from "./tickets.schemas";
import { ticketsService } from "./tickets.service";
export default createAuthMiddleware().post(
"/",
({ body, user }) => ticketsService.create(user.id, body.name),
{
body: CreateTicketSchema,
response: TicketResponse,
detail: { tags: ["Tickets"], security: [{ cookieAuth: [] }] },
}
);src/config/routes.ts— import + add toroutes.src/config/app.ts—.group("/api/v1/tickets", ...).src/config/swagger.ts— add the tag.
Throw ApiErrors.* from services. Routes auto-translate to a typed
response. throw new Error(...) produces a generic 500 — the elysia
plugin flags it.
throw ApiErrors.notFound("Ticket");
throw ApiErrors.validation("name is reserved", "name");logger.info("User registered", {
event: "auth.register.success", // required by structured-logging plugin
userId: created.id,
email: maskEmailForLogging(created.email), // PII must be masked
});Never String(error) — use getErrorMessage(error) (autofixable).
- Unit (
tests/lib/**,tests/auth/**) — pure-function tests, always run. - Integration (
tests/api/**) — hit Drizzle/Postgres; importrequireDbfromtests/helpers/db.tsand silently skip when no DB is reachable.
(cd ../../infra/compose/compose && ./dev.sh up)
bun run db:push
bun testtests/helpers/db.ts re-exports db, eq, and, or, and the schema
tables — integration tests must import from there, not from
drizzle-orm or clients/postgres/schema directly (test-conventions
plugin enforces this).
Every test file mirrors a source path:
tests/api/auth/services/auth.service.test.ts ↔
src/api/auth/services/auth.service.ts.
check is the source of truth. The lint config is intentionally strict:
- no
any(useunknown+ narrow) - no
ascasting (onlyas const) - no non-null
! - no floating promises, no unsafe enum comparisons
- exhaustive
switch - 14 architectural plugins (see AGENT_CONTRACT.md §ESLint plugins)
If a rule fights real intent, edit eslint.config.js. Never
eslint-disable inline.