How we built a decorator-driven Node.js framework adapter for Prisma ORM, what worked, and what didn't.
We recently shipped Prisma support for KickJS, our decorator-driven Node.js framework built on Express 5 and TypeScript. The goal was to make kick g module --repo prisma generate a fully working DDD module with Prisma repositories — no manual wiring needed.
Along the way, we built a full Jira-style project management API as a reference app, migrated it across Prisma 5/6 and Prisma 7, and ran into some interesting type safety challenges. Here's what we learned.
What We Built
The @forinda/kickjs-prisma adapter provides:
-
PrismaAdapter — lifecycle adapter that registers
PrismaClientin the DI container -
PrismaQueryAdapter — translates parsed query strings into Prisma
findManyarguments - PrismaModelDelegate — typed interface for model CRUD operations (more on this below)
-
CLI integration —
kick g module users --repo prismagenerates a complete DDD module with working Prisma repositories
The reference app (jira-prisma-api) is a 14-module Jira clone with auth, workspaces, projects, tasks, labels, comments, attachments, channels, messages, notifications, activities, and stats — all backed by Prisma + PostgreSQL.
The Adapter: Simple by Design
The adapter itself is intentionally thin:
export class PrismaAdapter implements AppAdapter {
name = 'PrismaAdapter'
private client: any
constructor(private options: PrismaAdapterOptions) {
this.client = options.client
}
beforeStart(_app: any, container: Container): void {
// Set up logging (Prisma 5/6 vs 7+ detection)
if (this.options.logging) {
if (typeof this.client.$on === 'function') {
// Prisma 5/6: event-based logging
this.client.$on('query', (event: any) => {
log.debug(`Query: ${event.query} — ${event.duration}ms`)
})
} else if (typeof this.client.$extends === 'function') {
// Prisma 7+: $on removed, use Client Extensions
this.client = this.client.$extends({
query: {
$allOperations({ operation, model, args, query }: any) {
const start = performance.now()
return query(args).then((result: any) => {
log.debug(`${model}.${operation} — ${Math.round(performance.now() - start)}ms`)
return result
})
},
},
})
}
}
container.registerFactory(PRISMA_CLIENT, () => this.client, Scope.SINGLETON)
}
async shutdown(): Promise<void> {
if (typeof this.client.$disconnect === 'function') {
await this.client.$disconnect()
}
}
}
Why client: any? Prisma's PrismaClient type is generated per-project — it includes your specific models, enums, and relations. The adapter can't import a concrete type without coupling to a specific schema. Using any keeps it version-agnostic across Prisma 5, 6, and 7.
Shortcoming #1: This means the adapter has zero compile-time knowledge of which models exist. You won't get a type error if you pass a misconfigured client. We considered using generics (PrismaAdapter<T extends PrismaClient>), but that would require the adapter package to depend on your specific generated client — defeating the purpose of a reusable package.
The Type Safety Problem
When we built the CLI template for kick g module --repo prisma, we hit a fundamental tension:
The template generates code at scaffold time, but the Prisma schema doesn't exist yet (or the model hasn't been added).
Our first attempt looked like this:
// Generated by: kick g module user --repo prisma
@Repository()
export class PrismaUserRepository {
@Inject(PRISMA_CLIENT) private prisma!: PrismaClient
async findById(id: string) {
return (this.prisma.user as any).findUnique({ where: { id } })
// ^^^^^^^^^^^^^^^^^^^^
// as any because 'user' model may not exist on PrismaClient yet
}
}
Every single Prisma call needed as any because:
- The generated
PrismaClienttype only includes models defined in your schema - The CLI template can't know which models will exist when you run it
- TypeScript can't verify
this.prisma.userwithout the generated types
Shortcoming #2: We had as any on every model accessor call — 6+ casts per repository file. For a framework that emphasizes TypeScript-first development, this was embarrassing.
The PrismaModelDelegate Solution
We introduced PrismaModelDelegate — a typed interface that describes the common CRUD operations every Prisma model delegate supports:
export interface PrismaModelDelegate {
findUnique(args: { where: Record<string, unknown>; include?: Record<string, unknown> }): Promise<unknown>
findFirst?(args?: Record<string, unknown>): Promise<unknown>
findMany(args?: Record<string, unknown>): Promise<unknown[]>
create(args: { data: Record<string, unknown> }): Promise<unknown>
update(args: { where: Record<string, unknown>; data: Record<string, unknown> }): Promise<unknown>
delete(args: { where: Record<string, unknown> }): Promise<unknown>
deleteMany(args?: { where?: Record<string, unknown> }): Promise<{ count: number }>
count(args?: { where?: Record<string, unknown> }): Promise<number>
}
Now the generated code type-narrows the injected client to just the model it needs:
@Repository()
export class PrismaUserRepository {
@Inject(PRISMA_CLIENT) private prisma!: { user: PrismaModelDelegate }
async findById(id: string) {
return this.prisma.user.findUnique({ where: { id } }) as Promise<User | null>
}
async create(dto: CreateUserDTO) {
return this.prisma.user.create({ data: dto as Record<string, unknown> }) as Promise<User>
}
}
No as any on the model accessor. The { user: PrismaModelDelegate } type tells TypeScript that this.prisma.user exists and has CRUD methods with typed signatures.
Shortcoming #3: Methods return Promise<unknown> instead of Promise<User>. You still need as Promise<User> casts on return types. This is a safe downcast (narrowing unknown to a known type), not an unsafe any cast — but it's still a cast.
Shortcoming #4: The data parameter is typed as Record<string, unknown>, not Prisma.UserCreateInput. You lose field-level validation on writes. Passing { invalidField: true } compiles but fails at runtime.
The Upgrade Path
For production apps that need full type safety, we document a simple upgrade:
// Generated (works immediately, PrismaModelDelegate)
@Inject(PRISMA_CLIENT) private prisma!: { user: PrismaModelDelegate }
// Upgraded (full Prisma type safety)
import type { PrismaClient } from '@prisma/client'
@Inject(PRISMA_CLIENT) private prisma!: PrismaClient
One line change. After that, this.prisma.user.create({ data: dto }) validates dto against Prisma.UserCreateInput at compile time, returns User without casts, and gives you full autocomplete on where, include, select, etc.
Shortcoming #5: This upgrade requires knowing your Prisma client import path, which differs between Prisma 5/6 (@prisma/client) and Prisma 7 (@/generated/prisma/client). We added modules.prismaClientPath to the CLI config to address this, but the generated code always starts with PrismaModelDelegate regardless.
Prisma 7 Migration: What Changed
Prisma 7 introduced several breaking changes that affected our adapter:
1. No more datasource.url in schema
// Prisma 6
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// Prisma 7
datasource db {
provider = "postgresql"
// url moved to prisma.config.ts
}
2. Driver adapters required
// Prisma 6
const prisma = new PrismaClient()
// Prisma 7
import { PrismaPg } from '@prisma/adapter-pg'
import pg from 'pg'
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL })
const prisma = new PrismaClient({ adapter: new PrismaPg(pool) })
3. $on removed for logging
Our adapter detects this at runtime with typeof this.client.$on === 'function' and falls back to $extends. This is the one change that required code in the adapter itself.
4. Client generated to custom output
// Prisma 7
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
All 47 import statements in our Jira example changed from @prisma/client to @/generated/prisma/client.
Shortcoming #6: The @prisma/client runtime package is still needed as a dependency even in Prisma 7 — the generated client imports from @prisma/client/runtime/client internally. This confused us initially when we tried to remove it.
The Query Adapter: Type-Safe Search Columns
Our PrismaQueryAdapter translates parsed URL query strings into Prisma findMany arguments:
GET /api/v1/users?page=2&limit=25&q=john&filter=role:eq:admin&sort=createdAt:desc
Becomes:
{
where: {
AND: [
{ role: { equals: 'admin' } },
{ OR: [
{ name: { contains: 'john', mode: 'insensitive' } },
{ email: { contains: 'john', mode: 'insensitive' } },
]}
]
},
orderBy: [{ createdAt: 'desc' }],
skip: 25,
take: 25,
}
We added a generic type parameter to validate searchColumns:
import type { User } from '@prisma/client'
const config: PrismaQueryConfig<User> = {
searchColumns: ['name', 'email'], // TypeScript validates these are User fields
// searchColumns: ['invalid'], // Compile error!
}
Shortcoming #7: This only validates searchColumns, not filterable or sortable fields. Those are validated at the controller layer via QueryParamsConfig from @forinda/kickjs-core, which uses plain string arrays. A unified type that validates across both layers would be better.
What We'd Do Differently
1. Start with the PrismaClient approach, not PrismaModelDelegate
PrismaModelDelegate was born from the constraint that the CLI generates code before the Prisma schema exists. In practice, most users add the model to their schema first, then generate the module. We should have made the full PrismaClient import the default and PrismaModelDelegate the fallback.
2. Generate a db/prisma.ts barrel file
Instead of importing PrismaClient in every repository, generate a single src/db/prisma.ts that re-exports the client and model types. Repositories import from there. When upgrading Prisma versions, you change one file.
3. Type-safe DTOs from Prisma schema
The generated DTOs use Zod schemas that duplicate what's already in the Prisma schema. Tools like zod-prisma-types or prisma-zod-generator could generate Zod schemas from the Prisma schema, eliminating the duplication.
4. Transaction support in the adapter
Our adapter registers a singleton PrismaClient. For transactions (prisma.$transaction), you need the client directly. We should expose a withTransaction helper or a @Transactional decorator that wraps the use case in prisma.$transaction.
Try It
# Scaffold a new project
npx @forinda/kickjs-cli new my-api
cd my-api
# Add Prisma
kick add prisma
# Generate a module
kick g module user --repo prisma
# Start dev server
kick dev
Or check out the full Jira clone examples:
- jira-prisma-api (Prisma 6)
- jira-prisma-v7-api (Prisma 7)
Summary of Shortcomings
| # | Issue | Impact | Status |
|---|---|---|---|
| 1 | Adapter uses client: any — no compile-time client validation |
Low — runtime errors are clear | By design |
| 2 | as any on every model accessor |
Fixed with PrismaModelDelegate | |
| 3 | PrismaModelDelegate returns Promise<unknown> — return casts needed |
Medium — safe narrowing, not unsafe any
|
By design |
| 4 |
data param typed as Record<string, unknown> — no field validation on writes |
Medium — runtime errors instead of compile-time | Upgrade to full PrismaClient |
| 5 | Prisma client import path differs between v6 and v7 | Low — prismaClientPath config handles it |
Fixed |
| 6 |
@prisma/client runtime still needed in Prisma 7 |
Low — confusing but functional | Documented |
| 7 |
searchColumns typed but filterable/sortable not unified |
Low — validated at different layers | Planned |
KickJS is an open-source, decorator-driven Node.js framework. If you're building REST APIs with TypeScript and want NestJS-like DX without the weight, check it out.
Top comments (0)