Skip to content

Repositories ​

KickJS generators produce a repository interface plus a DI token for every module, and a default in-memory implementation. This page shows how to implement that interface against a real @forinda/kickjs-db client — the persistence story that replaces the old built-in ORM repo presets.

What the generator produces ​

kick g module users emits a repository contract like this:

ts
// src/modules/users/domain/repositories/user.repository.ts
import { createToken } from '@forinda/kickjs'
import type { ParsedQuery } from '@forinda/kickjs'
import type { UserResponseDTO } from '../../application/dtos/user-response.dto'
import type { CreateUserDTO } from '../../application/dtos/create-user.dto'
import type { UpdateUserDTO } from '../../application/dtos/update-user.dto'

export interface IUserRepository {
  findById(id: string): Promise<UserResponseDTO | null>
  findAll(): Promise<UserResponseDTO[]>
  findPaginated(parsed: ParsedQuery): Promise<{ data: UserResponseDTO[]; total: number }>
  create(dto: CreateUserDTO): Promise<UserResponseDTO>
  update(id: string, dto: UpdateUserDTO): Promise<UserResponseDTO>
  delete(id: string): Promise<void>
}

export const USER_REPOSITORY = createToken<IUserRepository>('app/User/repository')

The service depends on IUserRepository (via USER_REPOSITORY), never on a concrete implementation. To swap from the in-memory default to a database-backed one, you write a class that implements the interface and bind it to the token in the module's register().

The schema ​

Declare the table the repository reads and writes (see Schema):

ts
// src/db/schema.ts
import { table, uuid, varchar, timestamp } from '@forinda/kickjs-db'

export const users = table('users', {
  id: uuid().primaryKey().defaultRandom(),
  email: varchar(255).notNull().unique(),
  name: varchar(120).notNull(),
  createdAt: timestamp().notNull().defaultNow(),
  updatedAt: timestamp().notNull().defaultNow(),
})

A database-backed repository ​

Implement IUserRepository against the injected client. Inject it through whichever token you registered the client under — the examples here use the shipped DB_PRIMARY, but an app-owned token (createToken<typeof db>('app/db')) types better because it carries your schema without an augmentation. See Getting started.

ts
// src/modules/users/infrastructure/repositories/db-user.repository.ts
import { Repository, Inject, HttpException } from '@forinda/kickjs'
import type { ParsedQuery } from '@forinda/kickjs'
import { DB_PRIMARY, type KickDbClient } from '@forinda/kickjs-db'

import type { IUserRepository } from '../../domain/repositories/user.repository'
import type { UserResponseDTO } from '../../application/dtos/user-response.dto'
import type { CreateUserDTO } from '../../application/dtos/create-user.dto'
import type { UpdateUserDTO } from '../../application/dtos/update-user.dto'

@Repository()
export class DbUserRepository implements IUserRepository {
  @Inject(DB_PRIMARY) private db!: KickDbClient

  async findById(id: string): Promise<UserResponseDTO | null> {
    const row = await this.db
      .selectFrom('users')
      .selectAll()
      .where('id', '=', id)
      .executeTakeFirst()
    return row ? toDTO(row) : null
  }

  async findAll(): Promise<UserResponseDTO[]> {
    const rows = await this.db
      .selectFrom('users')
      .selectAll()
      .orderBy('createdAt', 'desc')
      .execute()
    return rows.map(toDTO)
  }

  async findPaginated(parsed: ParsedQuery): Promise<{ data: UserResponseDTO[]; total: number }> {
    const data = await this.db
      .selectFrom('users')
      .selectAll()
      .orderBy('createdAt', 'desc')
      .limit(parsed.pagination.limit)
      .offset(parsed.pagination.offset)
      .execute()

    const totalRow = await this.db
      .selectFrom('users')
      .select((eb) => eb.fn.countAll<number>().as('count'))
      .executeTakeFirstOrThrow()

    return { data: data.map(toDTO), total: Number(totalRow.count) }
  }

  async create(dto: CreateUserDTO): Promise<UserResponseDTO> {
    const row = await this.db
      .insertInto('users')
      .values({ email: dto.email, name: dto.name }) // id / timestamps are generated
      .returningAll()
      .executeTakeFirstOrThrow()
    return toDTO(row)
  }

  async update(id: string, dto: UpdateUserDTO): Promise<UserResponseDTO> {
    const row = await this.db
      .updateTable('users')
      .set({ ...dto, updatedAt: new Date() })
      .where('id', '=', id)
      .returningAll()
      .executeTakeFirst()
    if (!row) throw HttpException.notFound('User not found')
    return toDTO(row)
  }

  async delete(id: string): Promise<void> {
    const result = await this.db.deleteFrom('users').where('id', '=', id).executeTakeFirst()
    if (Number(result.numDeletedRows) === 0) throw HttpException.notFound('User not found')
  }
}

@Repository() registers the class as a DI singleton, and the query builder rows are typed straight from your schema. The toDTO mapper converts the row (where createdAt is a Date) into the response DTO shape (where it's typically an ISO string):

ts
function toDTO(row: {
  id: string
  email: string
  name: string
  createdAt: Date
  updatedAt: Date
}): UserResponseDTO {
  return {
    id: row.id,
    email: row.email,
    name: row.name,
    createdAt: row.createdAt.toISOString(),
    updatedAt: row.updatedAt.toISOString(),
  }
}

Keep the mapper at the boundary

The repository is where the row shape (snake/camel, Date vs string) meets the DTO the rest of the app speaks. Doing the mapping here keeps services and controllers free of database concerns.

Binding the implementation ​

In the module's register(), bind the token to your DB-backed class so everything that injects USER_REPOSITORY (or IUserRepository) gets it. The only change from the generated module is swapping the implementation class:

ts
// src/modules/users/users.module.ts
import { defineModule } from '@forinda/kickjs'
import { USER_REPOSITORY } from './domain/repositories/user.repository'
import { DbUserRepository } from './infrastructure/repositories/db-user.repository'
import { UsersService } from './application/users.service'
import { UsersController } from './presentation/users.controller'

export const UsersModule = defineModule({
  name: 'users',
  controllers: [UsersController],
  build: () => ({
    register(container) {
      // Was InMemoryUserRepository — now DB-backed.
      container.registerClass(USER_REPOSITORY, DbUserRepository)
    },
  }),
})

Because the service only ever depends on the interface + token, no service or controller code changes when you swap implementations — the in-memory repo from the generator and this DB repo are interchangeable.

Transactions across repositories ​

When a use case spans multiple repositories, run them inside a single transaction. The transaction client (tx) is a fully-scoped KickDbClient, so a repository method can accept it instead of using the injected this.db:

ts
await this.db.transaction(async (tx) => {
  const user = await tx
    .insertInto('users')
    .values({ email, name })
    .returningAll()
    .executeTakeFirstOrThrow()

  await tx.insertInto('profiles').values({ userId: user.id }).execute()
})

A common pattern is to make repository methods accept an optional client (db: KickDbClient = this.db) so they can be called either standalone or inside a transaction. See Queries → Transactions for the full transaction and savepoint surface.

Repositories vs $extends ​

For simple, table-local query helpers you can also reach for db.$extends({ model }) (see Queries → Per-table methods). The repository-interface approach is the right fit when you want:

  • a stable contract the rest of the app depends on (the interface + token),
  • DTO mapping at the persistence boundary,
  • the ability to swap implementations (in-memory for tests, DB for production) without touching consumers.

Both can coexist — repositories for the persistence contract, $extends for ad-hoc reusable query shapes.

Released under the MIT License. Built with TypeScript — runs on Express, Fastify, or h3.