DEV Community

Cover image for Building a Jira Clone with KickJS and Prisma: Lessons from Supporting Prisma 5, 6, and 7
Orinda Felix Ochieng
Orinda Felix Ochieng

Posted on

Building a Jira Clone with KickJS and Prisma: Lessons from Supporting Prisma 5, 6, and 7

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 PrismaClient in the DI container
  • PrismaQueryAdapter — translates parsed query strings into Prisma findMany arguments
  • PrismaModelDelegate — typed interface for model CRUD operations (more on this below)
  • CLI integrationkick g module users --repo prisma generates 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()
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

Every single Prisma call needed as any because:

  1. The generated PrismaClient type only includes models defined in your schema
  2. The CLI template can't know which models will exist when you run it
  3. TypeScript can't verify this.prisma.user without 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>
}
Enter fullscreen mode Exit fullscreen mode

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>
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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) })
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Becomes:

{
  where: {
    AND: [
      { role: { equals: 'admin' } },
      { OR: [
        { name: { contains: 'john', mode: 'insensitive' } },
        { email: { contains: 'john', mode: 'insensitive' } },
      ]}
    ]
  },
  orderBy: [{ createdAt: 'desc' }],
  skip: 25,
  take: 25,
}
Enter fullscreen mode Exit fullscreen mode

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!
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Or check out the full Jira clone examples:

Summary of Shortcomings

# Issue Impact Status
1 Adapter uses client: any — no compile-time client validation Low — runtime errors are clear By design
2 CLI template had as any on every model accessor High 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)