Project structure
A one-file app is fine for a prototype. As soon as it grows, split it like this:
my-api/
├── drizzle/ migrations, written by `lyn db generate`
├── src/
│ ├── app.ts creates the app: config + routes
│ ├── index.ts starts it: `listen()`
│ ├── errors.ts your own error helpers
│ ├── db/
│ │ └── schema.ts your tables
│ ├── dto/
│ │ └── post.ts what the API returns, built from the rows
│ └── routes/
│ ├── boards.ts
│ └── posts.ts
├── test/
│ └── posts.test.ts
├── .env
└── package.jsonsrc/db/schema.ts and drizzle/ are where the lyn db commands look by default.
Create the app in one file, start it in another
app.ts builds the app and exports a function. Tests import it without starting a server; index.ts is the only file that calls listen().
// src/app.ts
import { Lyn } from "@rouret/lyn";
import * as schema from "./db/schema";
import { registerBoardRoutes } from "./routes/boards";
import { registerPostRoutes } from "./routes/posts";
const envConfig = {
port: { name: "PORT", type: "number" },
} as const;
export type App = Lyn<typeof envConfig, typeof schema>;
export type Db = App["db"];
export const createApp = ({ databaseUrl }: { databaseUrl?: string } = {}) => {
const app: App = new Lyn({
env: envConfig,
database: { schema, url: databaseUrl },
});
return registerPostRoutes(registerBoardRoutes(app));
};// src/index.ts
import { createApp } from "./app";
const app = createApp();
await app.listen(app.env.port);Appis the type of your app, with its environment variables and its database. Use it to type the route files.Dbis the type ofctx.db, for helper functions that take it.databaseUrllets tests point the app at a test database.
One file per resource
Each file exports a function that takes the app, adds its routes and returns it. Every route method returns the app, so the types follow:
// src/routes/posts.ts
import { z } from "@rouret/lyn";
import { eq } from "@rouret/lyn/db";
import type { App, Db } from "../app";
import { posts } from "../db/schema";
import { notFound } from "../errors";
const postParams = z.object({ id: z.string().uuid() });
const findPostOrThrow = async (db: Db, id: string) => {
const post = await db.query.posts.findFirst({ where: eq(posts.id, id) });
if (!post) throw notFound("Post not found");
return post;
};
export const registerPostRoutes = (app: App) =>
app
.get("/posts/:id", ({ db, params }) => findPostOrThrow(db, params.id), {
params: postParams,
})
.delete(
"/posts/:id",
async ({ db, params }) => {
await findPostOrThrow(db, params.id);
await db.delete(posts).where(eq(posts.id, params.id));
},
{ params: postParams }
);Keep the schemas a route file shares, such as postParams, at the top of that file.
What you return in one place
src/dto/ holds the mappers that turn a row into what the client receives. A new column then stays private until you add it there:
// src/dto/post.ts
import type { posts } from "../db/schema";
export const toPostDto = (post: typeof posts.$inferSelect) => ({
id: post.id,
title: post.title,
votes: post.voteCount,
});See Never return a database row.
Your errors in one place
// src/errors.ts
const httpError = (status: number, code: string, message: string) =>
Object.assign(new Error(message), { isLynError: true, status, code });
export const notFound = (message: string) => httpError(404, "NOT_FOUND", message);
export const forbidden = (message: string) => httpError(403, "FORBIDDEN", message);Scripts
{
"scripts": {
"dev": "NODE_ENV=development bun --hot run src/index.ts",
"start": "NODE_ENV=production bun run src/index.ts",
"test": "NODE_ENV=test LOG_LEVEL=silent bun test",
"typecheck": "tsc --noEmit",
"db:generate": "lyn db generate",
"db:migrate": "lyn db migrate"
}
}There is no build script: Bun runs TypeScript as is, in development and in production.