Skip to content

Talk to a database

How-to. Get a working, pooled, correctly-closed database connection into an application, and reach it through a port your domain owns. For the starter's full surface, see @btravstack/prisma.

1. Compose the starter

prismaDatabase(name)({ client }) is a module: it binds DATABASE_URL through Config, builds the Postgres driver adapter from it, and holds your client as a resourceful provider whose release closes the pool on every exit path.

ts
export const database = prismaDatabase("OrderDatabase")({
  client: (adapter) => new PrismaClient({ adapter }),
});

The client arrow is the one thing the starter cannot own: a Prisma client is generated from your schema, so there is no client type to ship. Whatever you return is what the port carries — apply @unthrown/prisma's extension here too, if you want the try* twins, and the graph holds the extended client rather than a bare one.

2. Declare the port your domain speaks

The port belongs to the application, not to the database. It names the thing the domain needs, with the domain's own error on the channel:

ts
class OrderNotFound extends TaggedError("OrderNotFound")<{ readonly id: string }> {}

export class OrderRepository extends Port("OrderRepository")<{
  readonly find: (
    id: string,
  ) => AsyncResult<{ readonly id: string; readonly quantity: number }, OrderNotFound>;
}> {}

Nothing in that names Prisma, which is what lets a test compose a different adapter and what stops a schema change reaching the domain.

3. Write the adapter behind it

One provider, injecting the starter's port — database.port, typed by exactly what your client arrow returned:

ts
export const prismaOrderRepository = Provider(OrderRepository)({
  inject: { db: database.port },
  sync: ({ db }) => ({
    find: (id) =>
      fromPromise(
        db.order.findUnique({ where: { id } }),
        // `fromPromise`'s second argument decides what a rejection becomes.
        // A driver failure is nobody's modeled outcome, so it goes to the
        // defect channel rather than arriving as an `OrderNotFound` the
        // caller would read as "no such order".
        (cause, defect) => defect(cause),
      ).flatMap((row) =>
        row === null
          ? // A miss IS a modeled outcome, and this is where it becomes one.
            ErrAsync(new OrderNotFound({ id }))
          : OkAsync({ id: row.id, quantity: row.quantity }),
      ),
  }),
});

The flatMap is where a null row becomes the domain's OrderNotFound — the one translation an adapter owes, and the reason the port's error channel says what it says.

4. Compose it into the root

ts
export const PersistenceModule = Module("Persistence")({
  imports: [database],
  provides: [prismaOrderRepository],
  exports: [OrderRepository],
  needs: [Env, Logger],
});

database goes in imports; OrderRepository is what the rest of the application sees. The client port itself stays private unless you export it — nothing outside this module should hold a Prisma client.

The module needs Env (for DATABASE_URL) and Logger (for the one debug line the starter writes when engine tracing's optional peer is absent). Both are satisfied at the composition root, and the kernel provides Env itself.

5. Migrations run before the process, never at boot

sh
npx prisma migrate deploy

That is a deployment step — a Job or a release command that runs to completion before the rollout, never something the application does to itself at startup. An application that migrates at boot races every other replica: three pods, three migrations, one of them losing.

6. Scope it to the tenant

If the application is multi-tenant, a tenantId in every where the adapter writes is a filter someone has to remember. @btravstack/prisma/rls's tenantScoped(tenant) moves that guarantee into PostgreSQL: applied last on the client, it pins every statement to tenant through a transaction-local set_config, and a row-level-security policy on the table is what narrows the query — so the tenant predicate leaves that table's reads and writes, and forgetting to name it stops being a way to read someone else's rows. examples/order-infrastructure/src/prisma-order-repository.ts's list is the worked case, and it names no tenant at all. The column and the key stay: save still writes tenantId in its data, and find and remove still address the composite tenantId_orderId.

The extension is one line; the DDL is the deployment's, and forgetting a piece of it fails quietly rather than loudly. Both halves, with what each looks like when it is missing, are on the reference page. The worked application is examples/order-infrastructure — the client in src/database.ts, the policy in prisma/migrations/20260906120000_order_rls/, and src/rls.spec.ts proving it against a real server.

It is the floor under the three layers that decide who a caller is and what they may do, not a replacement for any of them: Authorize a request is where they are stated.

What you get for free

  • DATABASE_URL validated once, as the graph builds: unset or blank is a ConfigInvalid naming the variable, which runMain prints and exits 78 for — not a crash on the first query.
  • The pool closed on every exit path, including a boot that failed after it opened.
  • A health check named after the starter, SELECT 1 through $queryRaw, folded into the kernel's /healthz with nothing wired.
  • Every query counted and its failures logged, through the Observers set port — compose observability() and otel() beside it and the instruments appear; compose neither and it costs one inert call.
  • Engine-level tracing when @prisma/instrumentation is installed, turned on by an OTel SDK being composed rather than by anything you write.

Testing it

The starter has no in-memory adapter, and that is deliberate: an "in-memory Prisma" for arbitrary SQL is not something anyone can write. Two honest options:

  • Swap at the port you declared. OrderRepository is your interface, so a test composes a Map-backed provider in its place — see Swap an adapter for tests.
  • Run the real database. A container per test suite, a tenant or a schema per test for isolation. That is what this repository's own examples do.

Where to go next

Released under the MIT License.