ORM: Prisma or Drizzle
One repository port per aggregate. Two interchangeable ORMs behind it. Picked once at scaffold time.
Orbit ships with two database access layers: Prisma (the default free track) and Drizzle (a paid track). Both sit behind the same repository interfaces in domain/, so the application and domain layers never import an ORM. Swapping between them is a scaffold-time choice — the CLI strips the one you didn't pick, and the generated project ships with a single, consistent stack.
Scaffold-time, not runtime. The ORM choice isn't a runtime flag. When you run create-orb my-app --orm-provider=drizzle, the CLI deletes every Prisma file, fenced block, and dependency. Generated projects only carry the one ORM they use.
Pick a track and the rest of the docs follow. Use the Prisma / Drizzle tabs on any page — your choice is remembered in this browser, so commands and code samples across the whole docs tree default to the ORM you picked. Switch at any time.
Picking an ORM
| Option | Tier | When to pick it |
|---|---|---|
prisma | Free (default) | Excellent type safety, DMMF-driven client, mature migrations, the path every Orbit guide assumes. Pick this unless you have a reason not to. |
drizzle | Paid | SQL-first query builder, smaller runtime, closer to raw SQL for people who want it. Same repository surface area — no application-code changes. |
CLI usage
scaffold with Prisma (default)
create-orb my-app# or explicitcreate-orb my-app --orm-provider=prismaThe --orm-provider flag maps to the orm.provider option in features.json. Picking drizzle enables the orm-drizzle sub-feature (paid) and strips orm-prisma, including the apps/api/prisma/ directory, the generated client, and every Prisma*Repository file.
What stays the same
Domain and application code is identical across both tracks — the repository interfaces are the contract, and they don't care which ORM implements them.
domain code runs unchanged on either track
await uow.run(async (tx) => { const user = await tx.users.findById(userId); if (!user) throw new Error("not found"); user.rename("New name"); await tx.users.save(user);});TxContextexposes the same repository ports (users,workspaces, …).- Domain events, projectors, and the
UnitOfWorksemantics are identical — both adapters extend a sharedBaseUnitOfWork. - better-auth swaps
@better-auth/prisma-adapter↔@better-auth/drizzle-adapterautomatically — the adapter site is fenced ininterfaces/http/better-auth.ts.
What differs
Schema source of truth
The prisma/schema.prisma file remains the single source of truth for column definitions even on the Drizzle track. The Drizzle schema in src/db/drizzle/schema.ts mirrors it one-to-one — when you add a column, update both files. This keeps the mental model simple: schema lives in one place, two ORMs know how to read it.
Migration commands
| Task | Prisma | Drizzle |
|---|---|---|
| Generate client / types | npm run prisma:generate | Types are auto-inferred from src/db/drizzle/schema.ts |
| Create a migration | npm run prisma:migrate | npm run drizzle:generate |
| Apply pending migrations | npm run prisma:migrate | npm run drizzle:migrate |
| Reset local DB | npm run prisma:reset | drizzle-kit drop + re-run migrate |
| Inspect in GUI | npx prisma studio | npm run drizzle:studio |
ID generation
Prisma uses a $extends client hook to auto-fill prefixed UUIDv7 ids on create / createMany. Drizzle doesn't have an equivalent, so Drizzle repositories call newId("user") (etc.) explicitly before db.insert(...). The net effect is the same — every row gets a typed, prefixed id — but the mechanism is explicit on the Drizzle side.
Under the hood
apps/api/src/kernel/base-uow.ts — the shared base
export abstract class BaseUnitOfWork<TxHandle> implements UnitOfWork { constructor(protected readonly bus: EventBus) {}
protected abstract openTransaction<T>(fn: (h: TxHandle) => Promise<T>): Promise<T>; protected abstract buildContext(handle: TxHandle): RepoContext; protected abstract readHandle(): TxHandle;
async run<T>(fn: (tx: TxContext) => Promise<T>): Promise<T> { /* shared */ } async read<T>(fn: (tx: TxContext) => Promise<T>): Promise<T> { /* shared */ }}Both PrismaUnitOfWork and DrizzleUnitOfWork extend this class and supply three small methods: how to open a transaction, how to build a repository context from a transactional handle, and how to return a non-transactional handle for reads. Event collection, read-only guards, and post-commit dispatch are inherited — the semantics are guaranteed identical between the two.
Files added / changed by the Drizzle strip
apps/api/drizzle/— drizzle-kit config and migrations folder.apps/api/src/db/drizzle/schema.ts— schema definitions mirroring the Prisma models.apps/api/src/infrastructure/drizzle.tsanddrizzle-uow.ts— client + unit of work.apps/api/src/**/infrastructure/drizzle-*.repository.ts— one file per repository interface.- npm scripts:
drizzle:generate,drizzle:migrate,drizzle:push,drizzle:studio.
Switching from Prisma to Drizzle later
If you scaffolded with Prisma and want to move to Drizzle on an existing project, there's no "swap" command — scaffold a fresh project with --orm-provider=drizzle, point it at your existing database, and copy over your domain, application, and interface code (none of which depend on the ORM). Drizzle's drizzle-kit pull can introspect an existing database if you want to verify the schemas match.
Both tracks share these tests. The test suite in apps/api/src/**/*.test.ts exercises services through UnitOfWork so the same tests cover both ORM paths — the adapter under the port is irrelevant.