Database and transactions
Beignet keeps database access behind app-owned ports. It gives you repository and Unit of Work conventions, but it does not hide Drizzle, Prisma, Kysely, or SQL behind a generic ORM abstraction.
The recommended framework path today is Drizzle with libSQL/Turso through
@beignet/provider-drizzle-turso.
Recommended structure
Keep schema, app repositories, and feature ports in predictable places:
infra/
db/
schema/
index.ts
posts.ts
comments.ts
repositories.ts
test-database.ts
posts/
drizzle-post-repository.ts
features/
posts/
ports.ts
tests/
persistence.test.ts
drizzle/
*.sql
drizzle.config.ts
Feature code owns the repository interface. Infra owns the Drizzle implementation. Server wiring adapts the raw Drizzle port into app-facing repository ports.
Repository ports
Use cases should depend on repository ports, not a raw database client:
// features/posts/ports.ts
export interface PostRepository {
findMany(input: ListPostsInput): Promise<{ posts: Post[]; total: number }>;
findBySlug(slug: string): Promise<Post | null>;
create(input: CreatePostInput): Promise<Post>;
}
Infrastructure adapts a concrete database to that port:
// infra/posts/drizzle-post-repository.ts
import { count, desc, eq } from "drizzle-orm";
import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
import type { PostRepository } from "@/features/posts/ports";
import * as schema from "@/infra/db/schema";
export function createDrizzlePostRepository(
db: DrizzleTursoDatabase<typeof schema>,
): PostRepository {
return {
async findMany(input) {
const rows = await db
.select()
.from(schema.posts)
.orderBy(desc(schema.posts.createdAt))
.limit(input.limit)
.offset(input.offset);
const [{ total }] = await db.select({ total: count() }).from(schema.posts);
return { posts: rows.map(toPost), total };
},
async findBySlug(slug) {
const [row] = await db
.select()
.from(schema.posts)
.where(eq(schema.posts.slug, slug))
.limit(1);
return row ? toPost(row) : null;
},
};
}
The key detail is the DrizzleTursoDatabase parameter. It accepts both the root
Drizzle database and a transaction client, so the same repository factory works
for normal reads and transaction-scoped writes.
Repository factory
Collect app repositories in one infra factory:
// infra/db/repositories.ts
import type { DrizzleTursoDatabase } from "@beignet/provider-drizzle-turso";
import { createDrizzlePostRepository } from "@/infra/posts/drizzle-post-repository";
import type { AppRepositoryPorts } from "@/ports";
import * as schema from "./schema";
export function createRepositories(
db: DrizzleTursoDatabase<typeof schema>,
): AppRepositoryPorts {
return {
posts: createDrizzlePostRepository(db),
};
}
This keeps server/index.ts from importing every repository adapter directly
and gives Unit of Work one place to create transaction-scoped ports.
Server wiring
The Drizzle provider installs the provider-owned db port. Your app context
should expose repository ports and Unit of Work to use cases:
// server/index.ts
import { createDrizzleTursoUnitOfWork } from "@beignet/provider-drizzle-turso";
import { createRepositories } from "@/infra/db/repositories";
createContext: async ({ ports }) => {
const repositories = createRepositories(ports.db.db);
return {
ports: {
...ports,
...repositories,
uow: createDrizzleTursoUnitOfWork({
db: ports.db.db,
eventBus: ports.eventBus,
createTransactionPorts: (tx, events) => ({
...createRepositories(tx),
events,
}),
}),
},
};
};
ctx.ports.db.db is an infrastructure escape hatch. Keep it out of use cases.
Use cases should call ctx.ports.posts or ctx.ports.uow.transaction(...).
Unit of work
Use ctx.ports.uow.transaction(...) when a workflow needs multiple operations
to commit or rollback together:
const createPostUseCase = useCase
.command("posts.create")
.input(CreatePostInputSchema)
.output(PostSchema)
.run(async ({ ctx, input }) =>
ctx.ports.uow.transaction((tx) => tx.posts.create(input)),
);
When a use case records domain events, expose the transaction-local recorder in your transaction ports and publish events after commit:
type AppTransactionPorts = AppRepositoryPorts & {
events: DomainEventRecorderPort;
};
uow: createDrizzleTursoUnitOfWork({
db: ports.db.db,
eventBus: ports.eventBus,
createTransactionPorts: (tx, events) => ({
...createRepositories(tx),
events,
}),
});
Then record events inside the transaction:
const post = await ctx.ports.uow.transaction(async (tx) => {
const created = await tx.posts.create(input);
await events.record(tx.events, postCreated, { postId: created.id });
return created;
});
If the transaction rolls back, recorded events are discarded. If it commits, the
helper validates, parses, and flushes them to eventBus after commit. This
avoids side effects like jobs, emails, and listeners observing data that never
actually committed.
After-commit side effects cannot roll back an already committed database
transaction. If event validation or publishing fails after commit,
transaction(...) rejects but the database transaction is already committed.
Use an outbox when events or jobs need durable delivery guarantees.
Migrations and local setup
Keep Drizzle CLI config at the app root:
// drizzle.config.ts
export default {
schema: "./infra/db/schema/index.ts",
out: "./drizzle",
dialect: "sqlite",
dbCredentials: {
url: process.env.TURSO_DB_URL ?? "file:local.db",
authToken: process.env.TURSO_DB_AUTH_TOKEN,
},
};
Use normal Drizzle commands when the schema changes:
bun run db:generate
bun run db:migrate
The example app self-bootstraps a local SQLite database so it is easy to run, but production apps should treat migrations as a deployment step.
Testing
Repository tests should run against an isolated local database. Keep the helper in infra and the behavior test with the feature:
// infra/db/test-database.ts
import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import { createRepositories } from "./repositories";
import * as schema from "./schema";
export async function createTestDatabase() {
const client = createClient({ url: "file::memory:" });
await ensureSchema(client);
const db = drizzle(client, { schema });
return {
repositories: createRepositories(db),
close: async () => {
client.close();
},
};
}
// features/posts/tests/persistence.test.ts
const { repositories, close } = await createTestDatabase();
const post = await repositories.posts.create({
title: "Database conventions",
content: "Use repository ports from use cases.",
});
expect(await repositories.posts.findBySlug(post.slug)).toMatchObject({
id: post.id,
});
close();
Use createNoopUnitOfWork(...) for pure use-case tests that do not need a real
database transaction. Use a real local database test when the behavior belongs
to SQL, indexes, joins, constraints, or repository mapping.
When to use what
Use ctx.ports.posts directly for simple reads and operations that do not need
a transaction. Use ctx.ports.uow.transaction(...) for writes that coordinate
multiple repositories, emit domain events, enqueue jobs, send notifications, or
need a clear commit boundary.