Skip to content

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.

Terminal window
DATABASE_ORM=prisma # or typeorm; left empty, drizzle

Set 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.

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.

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's

Its 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.

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).