Database and ORMs
The API stores everything in Postgres, through Drizzle, Prisma or TypeORM. You choose one when you set the product up, and write your own features with it. The kit’s own modules work with any of them.
Choose an ORM
Section titled “Choose an ORM”DATABASE_ORM=prisma # or typeorm; left empty, drizzleSet it in .env, and in .env.railway for production, before the first migration. Choose
once. Each ORM keeps its own migrations and its own record of which ran, so changing later
means moving your migrations from one to the other by hand.
| Drizzle | Prisma | TypeORM | |
|---|---|---|---|
| Tables | apps/api/src/db/schema/*.ts |
apps/api/prisma/schema.prisma |
apps/api/src/db/typeorm/entities/*.ts |
| Migrations | apps/api/drizzle |
apps/api/prisma/migrations |
apps/api/src/db/typeorm/migrations |
| What your code uses | DrizzleDatabase, client |
PrismaDatabase, client |
TypeOrmDatabase, manager |
bun run db:generate |
Writes a migration | Writes a migration, and the client again | Compares the entities with your database, and writes a migration |
Prisma’s client is generated into apps/api/src/db/prisma/client and committed, so neither the
image nor CI has to generate it. Prisma’s CLI is for development: in production, db:migrate
applies Prisma’s migrations itself and records them exactly as prisma migrate deploy would, so
the image carries neither the CLI nor its engines. TypeORM’s migrations are TypeScript, run by
TypeORM itself; db:generate diffs the entities against the database in DATABASE_URL, so
migrate it before you generate. All three schemas are in every copy, whichever you chose: kit
updates change all three, and merge without conflicts either way.
Transactions
Section titled “Transactions”Code does not pass a transaction around. It opens one, and everything inside it takes part:
await this.database.transaction(async () => { await this.db.client.delete(projects).where(/* … */); await this.audit.record({ workspaceId, actor, action: 'project.deleted', target }); await this.mail.queue(email, 'projectDeleted', props);});The project, its audit event and the queued email are saved together or not at all. Each ORM’s
client (TypeORM’s manager) is the open transaction inside the callback and the connection outside it, kept in
AsyncLocalStorage. Calling transaction inside another joins it; transactions do not nest.
Database (apps/api/src/db/database.ts) is what services inject for this.
How the kit’s modules work with either
Section titled “How the kit’s modules work with either”Each kit module that stores something reads and writes through a repository: an abstract class with one implementation per ORM, next to it:
apps/api/src/audit/ audit.service.ts what the module does: no ORM audit.repository.ts what it needs from the database audit.drizzle.ts Drizzle's audit.prisma.ts Prisma's audit.typeorm.ts TypeORM'sIts module provides the one for DATABASE_ORM:
forOrm(AuditRepository, { drizzle: DrizzleAuditRepository, prisma: PrismaAuditRepository, typeorm: TypeOrmAuditRepository,});Better Auth uses its own adapter for Drizzle and for Prisma, on the connection. It has none for
TypeORM, so there it uses its Kysely adapter, on TypeORM’s pool. The job queue claims jobs with
one SQL statement written once (apps/api/src/jobs/claim.sql.ts), because it needs Postgres’s
FOR UPDATE SKIP LOCKED.
Your own features need none of this. Write them with the ORM you chose, as on Add a resource.
The three schemas cannot drift
Section titled “The three schemas cannot drift”apps/api/test/schema.e2e.test.ts migrates an empty database with each ORM and compares the kit’s
tables in all three: every column, type, default, key, constraint and index. It also checks that
Prisma and TypeORM, looking at the result, find nothing to change. If you add a column to one of
the kit’s tables, add it to all three schemas. Your own tables only need the ORM you chose.
CI runs every API test on Drizzle, then again on Prisma (bun run test:prisma) and on TypeORM
(bun run test:typeorm).