Tutorial · Step 8 of 14
Persistence
A real database in the startup context — pool, schema, service, CRUD.
The database as a startup resource#
A database is the canonical startup resource: open the pool once, assign the query handle to the context, and keep close() where the code that opened it can reach it. This example uses Drizzle on Bun's native SQL driver; the data recipes cover Prisma, MikroORM, and MongoDB with the same shape:
// src/database.ts
import { SQL } from "bun";
import { drizzle, type BunSQLDatabase } from "drizzle-orm/bun-sql";
import * as schema from "./notes/notes.table";
export type Database = BunSQLDatabase<typeof schema>;
export interface DatabaseHandle {
db: Database;
close(): Promise<void>;
}
export function createDatabase(url: string): DatabaseHandle {
const client = new SQL(url);
return { db: drizzle({ client, schema }), close: () => client.close() };
}Schema and service#
The table definition is plain Drizzle, and the service factory from step 3 now takes the database instead of a Map — the controller does not change at all:
// src/notes/notes.table.ts
import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
export const notes = pgTable("notes", {
id: uuid("id").primaryKey().defaultRandom(),
title: text("title").notNull(),
content: text("content").notNull().default(""),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export type Note = typeof notes.$inferSelect;// src/notes/notes.service.ts
import { desc, eq } from "drizzle-orm";
import type { Database } from "../database";
import { notes, type Note } from "./notes.table";
import type { CreateNoteInput, UpdateNoteInput } from "./notes.schema";
export function createNotesService(db: Database) {
return {
list(): Promise<Note[]> {
return db.select().from(notes).orderBy(desc(notes.createdAt));
},
async get(id: string): Promise<Note | undefined> {
const [note] = await db.select().from(notes).where(eq(notes.id, id));
return note;
},
async create(input: CreateNoteInput): Promise<Note> {
const [note] = await db.insert(notes).values(input).returning();
return note;
},
async update(id: string, patch: UpdateNoteInput): Promise<Note | undefined> {
const [note] = await db
.update(notes)
.set({ ...patch, updatedAt: new Date() })
.where(eq(notes.id, id))
.returning();
return note;
},
async remove(id: string): Promise<boolean> {
const deleted = await db.delete(notes).where(eq(notes.id, id)).returning({ id: notes.id });
return deleted.length > 0;
},
};
}
export type NotesService = ReturnType<typeof createNotesService>;Wire it together#
The startup now reads as a dependency graph: config first, then the pool (consuming config), then the service (consuming the pool), then the controller (consuming the service). The app module declares the pool's shape, the notes module derives its service from it, and main.ts assigns the real pool and owns shutting it down:
// src/notes/notes.module.ts
export type NotesModuleInput = RhythmHttpContext & { db: Database };
export const notesModule = new Rhythm<NotesModuleInput>({ name: "notes", type: "module" })
.use(derive(({ db }: NotesModuleInput) => ({ notesService: createNotesService(db) })))
.use(notesController.middleware());// src/app.module.ts
export const appModule = new Rhythm<RhythmHttpContext, { db: Database }>({ name: "app", type: "module" })
.use(filter())
.register(await configModule.forRoot(databaseConfig), (v) => v)
.register(notesModule)
.use(() => {
throw new HttpError(404, "Route not found");
});// src/main.ts
const { url } = await databaseConfig();
const database = createDatabase(url);
appModule.context.db = database.db;
const server = Bun.serve({ port, fetch: toFetchHandler(appModule) });
process.on("SIGTERM", async () => {
await server.stop();
await database.close(); // whoever opened the pool closes it
process.exit(0);
});register(notesModule) is type-checked: the app's input plus its startup shape must satisfy the notes module's input, so forgetting to declare db is a compile error, not a 3 a.m. surprise.
For local development, run Postgres from a compose file bound to loopback only — never publish a dev database on all interfaces:
# compose.yaml
services:
postgres:
image: postgres:17
environment:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: notes
ports:
- "127.0.0.1:5432:5432"