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.

For production dependency placement and the pinned facade's upstream runtime-only packaging blocker, see Production dependency closure. Contract emission belongs in the build stage and migrations in deployment Jobs.

1. Compose the starter ​

prismaDatabase(name)({ client }) is a module: it binds DATABASE_URL through Config, hands it to your client factory along with its own middleware, and holds the result as a resourceful provider whose release closes the pool on every exit path.

ts
import { contractFromJson } from "@prisma/orm-postgres/static";

const contract = contractFromJson<Contract>(contractJson);
export const database = prismaDatabase("OrderDatabase")({
  client: ({ url, middleware }) => postgres({ contract, url, middleware }),
});

The client arrow is the one thing the starter cannot own: a Prisma 8 client is typed by your emitted Contract and built from yourcontract.json, so there is no client type to ship. Whatever you return is what the port carries. Contract is the type your contract emission generated; contractFromJson<Contract>(contractJson) validates its JSON once and supplies the typed value Prisma rc.19 and later clients accept.

Spread middleware into the client rather than dropping it: that is the starter's own statement middleware, and it is where the per-query observation below comes from.

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) =>
      // `tryQuery` takes a THUNK, so the query starts inside the Result rather
      // than before it, and turns the database's SQLSTATEs into tagged errors.
      tryQuery(() => db.orm.orders.Order.first({ id }))
        .mapErrCases((matcher, defect) =>
          // None of them is a modeled outcome of "find an order", so they go
          // to the defect channel rather than arriving as an `OrderNotFound`
          // the caller would read as "no such order".
          matcher.with(
            P.tag("UniqueConstraintViolation"),
            P.tag("ForeignKeyViolation"),
            P.tag("NotAuthorized"),
            (cause) => defect(cause),
          ),
        )
        // A miss IS a modeled outcome, and this is where it becomes one.
        .flatMap((row) => fromNullable(row, () => new OrderNotFound({ id })).toAsync()),
  }),
});

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],
});

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 nothing else — the kernel provides it. The starter needed a Logger under Prisma 7, for one debug line about engine tracing's optional peer; Prisma 8 has no engine and no such package, so that need went with it.

5. Migrations run before the process, never at boot ​

sh
npx prisma db migrate

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. Row-level security moves that guarantee into PostgreSQL, and both halves are declared rather than written by hand.

The policy is in the contract, so the planner emits it like any other operation:

prisma
namespace orders {
  model Order {
    id       Int    @id @default(autoincrement())
    tenantId String
    orderId  String

    @@unique([tenantId, orderId])
    @@rls
  }

  policy_all order_tenant_isolation {
    target    = Order
    using     = sql`"tenantId" = current_setting('app.tenant_id', true)`
    withCheck = sql`"tenantId" = current_setting('app.tenant_id', true)`
  }
}

The policy_* block lives inside the namespace it polices and names its target unqualified; target = orders.Order from the top level fails emit with PSL_INVALID_EXTENSION_BLOCK_MEMBER.

The pin is a transaction, from @btravstack/prisma/rls:

ts
const orders = await tenantPinned(db, tenant, (tx) => tx.orm.orders.Order.all());

set_config(..., true) is transaction-local, which is why this opens one rather than handing back a pinned client: a session-scoped pin works only while the pool happens to return the same connection, and fails silently the first time it does not. Inside it the tenant predicate leaves that table's reads and writes — examples/order-infrastructure's list names no tenant at all — and forgetting to name it stops being a way to read someone else's rows. The column and the key stay: save still writes tenantId, and find and remove still address (tenantId, orderId).

Two things are still yours, and both fail quietly rather than loudly: Prisma 8 authors policies but not GRANTs, and it emits ENABLE ROW LEVEL SECURITY with no way to say FORCE — so the table's owner bypasses every policy. Connect as a non-owner role. Both, with what each looks like when it is missing, are on the reference page; the worked application is examples/order-infrastructure, with src/rls.spec.ts proving it against a real server as a NOSUPERUSER NOBYPASSRLS role.

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 the raw lane, 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. The starter's middleware is what reports them, so it sees the ORM lane, the SQL builder and the raw lane alike, with the runtime's own latencyMs.

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.