DEV Community

Cover image for Build a Task Management API with KickJS — Categories, User Assignment, and Typed Everything
Orinda Felix Ochieng
Orinda Felix Ochieng

Posted on

Build a Task Management API with KickJS — Categories, User Assignment, and Typed Everything

A practical walkthrough of building a production-grade REST API in under an hour using decorators, dependency injection, and code generation.


Why KickJS?

If you've ever stared at an Express project and wished it had NestJS ergonomics without the learning curve, KickJS is the framework I built for exactly that itch. It's Express 5 underneath, so everything you already know still works. On top, it adds the stuff you end up writing anyway: a DI container, decorators, module system, Zod-powered validation, Vite HMR, and generators that scaffold a complete feature in about two seconds.

In this article, we're going to build a real task management API. Not a todo list with three endpoints — a proper backend with users, categories, and tasks that can be assigned to users. By the end, you'll have:

  • Typed end-to-end request handling (no any, no manual casts)
  • A clean DDD structure with controllers, services, repositories, and DTOs
  • User assignment with validation that the user actually exists
  • Category-based filtering with pagination, sorting, and search — for free
  • Auto-generated OpenAPI docs at /docs
  • Live-reloading dev server that preserves database connections

Total scaffolding time: ~5 seconds per module. Total writing time: the 20 minutes you spend reading this article.

Let's build it.


What We're Building

Here's the data model at a glance:

User
  id            uuid
  name          string
  email         string (unique)

Category
  id            uuid
  name          string
  color         string  (hex)
  description   string? (optional)

Task
  id            uuid
  title         string
  description   string? (optional)
  status        enum('todo' | 'in_progress' | 'done')
  priority      enum('low' | 'medium' | 'high')
  dueDate       string? (ISO date)
  categoryId    uuid    (FK to Category)
  assigneeId    uuid?   (FK to User, optional)
Enter fullscreen mode Exit fullscreen mode

Endpoints we want:

Method Route Description
GET /api/v1/users List users (paginated, searchable)
POST /api/v1/users Create a user
GET /api/v1/users/:id Get one user
GET /api/v1/categories List categories
POST /api/v1/categories Create a category
GET /api/v1/tasks List tasks (filter by status, category, assignee)
POST /api/v1/tasks Create a task
GET /api/v1/tasks/:id Get one task
PUT /api/v1/tasks/:id Update a task
PATCH /api/v1/tasks/:id/assign Assign (or unassign) a task
DELETE /api/v1/tasks/:id Delete a task

Nothing you haven't seen before. The interesting part is how little code it takes to get there.


Step 1: Scaffold the Project

First, make sure you have Node 20 or newer. Then create the project with a single command:

npx @forinda/kickjs-cli new task-api \
  --template rest \
  --pm pnpm \
  --repo inmemory \
  --git --install
Enter fullscreen mode Exit fullscreen mode

All flags are optional — leaving them off triggers interactive prompts — but passing them keeps the scaffold non-interactive, which is handy for scripts or if you just don't want to answer questions. Let me explain what each flag does:

  • --template rest — generates a REST API with Swagger and DevTools preconfigured. Other options are graphql, ddd, cqrs, and minimal.
  • --pm pnpm — uses pnpm. npm and yarn also work.
  • --repo inmemory — the generated repositories use an in-memory Map. For production you'd pick prisma or drizzle, but for a tutorial this gets us running without a database. (More on swapping repos later.)
  • --git --install — initializes a git repo and runs pnpm install. The CLI is smart enough to install dependencies before creating the initial commit, so your lockfile ends up in the first commit.

When it finishes, cd in:

cd task-api
Enter fullscreen mode Exit fullscreen mode

Here's the project structure you get:

task-api/
├── src/
│   ├── config/
│   │   └── index.ts              # Env schema (Zod-validated)
│   ├── index.ts                  # Entry point — calls bootstrap()
│   └── modules/
│       ├── hello/                # Sample module (we'll delete this)
│       │   ├── hello.controller.ts
│       │   ├── hello.module.ts
│       │   └── hello.service.ts
│       └── index.ts              # Exports the modules array
├── .env / .env.example
├── AGENTS.md                     # AI agent guide
├── CLAUDE.md                     # AI development guide
├── README.md
├── kick.config.ts                # CLI configuration
├── package.json
├── tsconfig.json
├── vite.config.ts
└── vitest.config.ts
Enter fullscreen mode Exit fullscreen mode

Take a quick look at src/index.ts. This is your whole entry point:

import 'reflect-metadata'
import './config' // registers env schema before bootstrap
import { bootstrap } from '@forinda/kickjs'
import { modules } from './modules'

// Export the app so the Vite plugin can pick it up in dev mode.
// In production, bootstrap() auto-starts the HTTP server.
export const app = await bootstrap({ modules })
Enter fullscreen mode Exit fullscreen mode

That's it. No Express app construction, no manual middleware wiring, no app.listenbootstrap() handles it. The import './config' is important: it's a side-effect import that registers your Zod env schema with the framework before any service resolves a config value. Keep it there.

Start the dev server to make sure everything works:

pnpm kick dev
Enter fullscreen mode Exit fullscreen mode

You should see Server running on http://localhost:5173 — that's the Vite dev server default port. Hit http://localhost:5173/api/v1/hello and you'll get a cheerful greeting back. (If you want a different port, pass -p 3000 to kick dev or set PORT=3000 in your .env.) Delete the hello module — we'll replace it with real code.

pnpm kick rm module hello --force
Enter fullscreen mode Exit fullscreen mode

The rm command deletes the module directory and un-registers it from src/modules/index.ts. The CLI cleans up after itself.


Step 2: Generate the User Module

Here's where KickJS starts earning its keep. Instead of hand-writing a controller, service, repository, DTOs, entities, and use-cases, you describe the fields and let the generator do it:

pnpm kick g scaffold user \
  name:string \
  email:email
Enter fullscreen mode Exit fullscreen mode

That's one command. It creates 16 files across the DDD layers:

src/modules/users/
├── index.ts                                          # Module wiring
├── constants.ts                                      # Query config
├── presentation/
│   └── user.controller.ts                            # Full CRUD controller
├── application/
│   ├── dtos/
│   │   ├── create-user.dto.ts                        # Zod schema for POST
│   │   ├── update-user.dto.ts                        # Zod schema for PUT
│   │   └── user-response.dto.ts                      # Response shape
│   └── use-cases/
│       ├── create-user.use-case.ts
│       ├── get-user.use-case.ts
│       ├── list-users.use-case.ts
│       ├── update-user.use-case.ts
│       └── delete-user.use-case.ts
├── domain/
│   ├── entities/user.entity.ts                       # Domain entity
│   ├── value-objects/user-id.vo.ts                   # Typed ID
│   ├── repositories/user.repository.ts               # Interface + DI token
│   └── services/user-domain.service.ts               # Domain logic
└── infrastructure/
    └── repositories/in-memory-user.repository.ts     # Working implementation
Enter fullscreen mode Exit fullscreen mode

Let me show you what the generated DTO looks like — this is the one place where the field definitions you gave the CLI actually show up:

// src/modules/users/application/dtos/create-user.dto.ts
import { z } from 'zod'

export const createUserSchema = z.object({
  name: z.string(),
  email: z.string().email(),
})

export type CreateUserDTO = z.infer<typeof createUserSchema>
Enter fullscreen mode Exit fullscreen mode

The email:email field type tells the scaffold generator to use z.string().email() instead of the plain z.string() it would use for a string field. There are twelve built-in types — string, text, number, int, float, boolean, date, email, url, uuid, json, and enum:a,b,c — which cover the vast majority of what you need for HTTP APIs.

The generated controller is worth showing in full because it demonstrates something I think KickJS does really well:

// src/modules/users/presentation/user.controller.ts
import {
  Controller, Get, Post, Put, Delete,
  Autowired, ApiQueryParams, type Ctx,
} from '@forinda/kickjs'
import { ApiTags } from '@forinda/kickjs-swagger'
import { CreateUserUseCase } from '../application/use-cases/create-user.use-case'
import { GetUserUseCase } from '../application/use-cases/get-user.use-case'
import { ListUsersUseCase } from '../application/use-cases/list-users.use-case'
import { UpdateUserUseCase } from '../application/use-cases/update-user.use-case'
import { DeleteUserUseCase } from '../application/use-cases/delete-user.use-case'
import { createUserSchema } from '../application/dtos/create-user.dto'
import { updateUserSchema } from '../application/dtos/update-user.dto'
import { USER_QUERY_CONFIG } from '../constants'

@Controller()
export class UserController {
  @Autowired() private readonly createUserUseCase!: CreateUserUseCase
  @Autowired() private readonly getUserUseCase!: GetUserUseCase
  @Autowired() private readonly listUsersUseCase!: ListUsersUseCase
  @Autowired() private readonly updateUserUseCase!: UpdateUserUseCase
  @Autowired() private readonly deleteUserUseCase!: DeleteUserUseCase

  @Get('/')
  @ApiTags('User')
  @ApiQueryParams(USER_QUERY_CONFIG)
  async list(ctx: Ctx<KickRoutes.UserController['list']>) {
    return ctx.paginate(
      (parsed) => this.listUsersUseCase.execute(parsed),
      USER_QUERY_CONFIG,
    )
  }

  @Get('/:id')
  @ApiTags('User')
  async getById(ctx: Ctx<KickRoutes.UserController['getById']>) {
    const result = await this.getUserUseCase.execute(ctx.params.id)
    if (!result) return ctx.notFound('User not found')
    ctx.json(result)
  }

  @Post('/', { body: createUserSchema, name: 'CreateUser' })
  @ApiTags('User')
  async create(ctx: Ctx<KickRoutes.UserController['create']>) {
    const result = await this.createUserUseCase.execute(ctx.body)
    ctx.created(result)
  }

  @Put('/:id', { body: updateUserSchema, name: 'UpdateUser' })
  @ApiTags('User')
  async update(ctx: Ctx<KickRoutes.UserController['update']>) {
    const result = await this.updateUserUseCase.execute(ctx.params.id, ctx.body)
    ctx.json(result)
  }

  @Delete('/:id')
  @ApiTags('User')
  async remove(ctx: Ctx<KickRoutes.UserController['remove']>) {
    await this.deleteUserUseCase.execute(ctx.params.id)
    ctx.noContent()
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice the ctx: Ctx<KickRoutes.UserController['create']> signature. That type isn't written by hand — it's generated by kick typegen, which runs automatically when you start the dev server. It scans your controllers and produces a global KickRoutes namespace with one interface per controller, where each method has its own typed params, body, query, and response.

The upshot: inside create, when you type ctx.body., your editor autocompletes name and email because they came from the createUserSchema. If you add a field to the Zod schema, the type updates on the next save. If you rename createUserSchema to something else, TypeScript tells you where to fix it. No manual z.infer<>, no duplicated types between the DTO and the handler.

The Query Parsing You Didn't Write

Look at the list method:

@Get('/')
@ApiQueryParams(USER_QUERY_CONFIG)
async list(ctx: Ctx<KickRoutes.UserController['list']>) {
  return ctx.paginate(
    (parsed) => this.listUsersUseCase.execute(parsed),
    USER_QUERY_CONFIG,
  )
}
Enter fullscreen mode Exit fullscreen mode

That ctx.paginate() call gives you pagination, filtering, sorting, and search — all from the query string — with zero additional code. The USER_QUERY_CONFIG constant was also generated:

// src/modules/users/constants.ts
import type { ApiQueryParamsConfig } from '@forinda/kickjs'

export const USER_QUERY_CONFIG: ApiQueryParamsConfig = {
  filterable: ['name', 'email'],
  sortable: ['name', 'email', 'createdAt', 'updatedAt'],
  searchable: ['name', 'email'],
}
Enter fullscreen mode Exit fullscreen mode

With this in place, a client can hit:

GET /api/v1/users?filter[name]=alice&sort=-createdAt&page=2&limit=10&search=example.com
Enter fullscreen mode Exit fullscreen mode

...and get back a paginated response with the filters applied, results sorted, and text search hitting any field you marked as searchable. The response has the shape:

{
  "data": [/* ... */],
  "meta": {
    "page": 2,
    "limit": 10,
    "total": 42,
    "totalPages": 5
  }
}
Enter fullscreen mode Exit fullscreen mode

You didn't write any of that. It's all in ctx.paginate().


Step 3: Generate the Category Module

Same deal, different fields:

pnpm kick g scaffold category \
  name:string \
  color:string \
  description:text:optional
Enter fullscreen mode Exit fullscreen mode

Two things worth noting here. First, description:text:optional uses the :optional suffix to mark the field as optional. You might be wondering why not description?:text, since ? is the TypeScript convention. The answer is shell glob expansion: ? is a single-character wildcard in bash and zsh, so description?:text without quotes would get expanded to match files in the current directory before the CLI ever sees it. The :optional suffix avoids that entirely and works in any shell without escaping.

Second, the text type is just an alias for string that hints at longer content. It's still z.string() and still a string in TypeScript — the distinction only matters if your repository uses it to pick a column type (Drizzle/Prisma generators use text for longer columns).

The generated DTO:

// src/modules/categories/application/dtos/create-category.dto.ts
import { z } from 'zod'

export const createCategorySchema = z.object({
  name: z.string(),
  color: z.string(),
  description: z.string().optional(),
})

export type CreateCategoryDTO = z.infer<typeof createCategorySchema>
Enter fullscreen mode Exit fullscreen mode

No surprises. Run your dev server again (if you stopped it), and you'll see routes for /api/v1/users and /api/v1/categories already live.


Step 4: The Task Module — Where It Gets Interesting

Tasks have two foreign keys (category and assignee), and the scaffold generator can't enforce foreign-key validation for us — that's domain logic. So we'll scaffold the basic shape and then customize it.

pnpm kick g scaffold task \
  title:string \
  description:text:optional \
  status:enum:todo,in_progress,done \
  priority:enum:low,medium,high \
  dueDate:date:optional \
  categoryId:uuid \
  assigneeId:uuid:optional
Enter fullscreen mode Exit fullscreen mode

The scaffold generator handles enum fields, optional fields, and UUID fields out of the box. Here's what the create DTO looks like:

// src/modules/tasks/application/dtos/create-task.dto.ts
import { z } from 'zod'

export const createTaskSchema = z.object({
  title: z.string(),
  description: z.string().optional(),
  status: z.enum(['todo', 'in_progress', 'done']),
  priority: z.enum(['low', 'medium', 'high']),
  dueDate: z.string().datetime().optional(),
  categoryId: z.string().uuid(),
  assigneeId: z.string().uuid().optional(),
})

export type CreateTaskDTO = z.infer<typeof createTaskSchema>
Enter fullscreen mode Exit fullscreen mode

Now we need to teach the task service to validate foreign keys. This is where dependency injection starts paying off. The generated CreateTaskUseCase currently looks like this:

// src/modules/tasks/application/use-cases/create-task.use-case.ts
import { Service, Inject } from '@forinda/kickjs'
import { TASK_REPOSITORY, type ITaskRepository } from '../../domain/repositories/task.repository'
import type { CreateTaskDTO } from '../dtos/create-task.dto'

@Service()
export class CreateTaskUseCase {
  constructor(@Inject(TASK_REPOSITORY) private repo: ITaskRepository) {}

  async execute(dto: CreateTaskDTO) {
    return this.repo.create(dto)
  }
}
Enter fullscreen mode Exit fullscreen mode

We want to check that the categoryId exists, and — if assigneeId is provided — that the user exists too. Update the use case:

import { Service, Inject, HttpException } from '@forinda/kickjs'
import { TASK_REPOSITORY, type ITaskRepository } from '../../domain/repositories/task.repository'
import { USER_REPOSITORY, type IUserRepository } from '../../../users/domain/repositories/user.repository'
import { CATEGORY_REPOSITORY, type ICategoryRepository } from '../../../categories/domain/repositories/category.repository'
import type { CreateTaskDTO } from '../dtos/create-task.dto'

@Service()
export class CreateTaskUseCase {
  constructor(
    @Inject(TASK_REPOSITORY) private readonly tasks: ITaskRepository,
    @Inject(USER_REPOSITORY) private readonly users: IUserRepository,
    @Inject(CATEGORY_REPOSITORY) private readonly categories: ICategoryRepository,
  ) {}

  async execute(dto: CreateTaskDTO) {
    // Verify the category exists
    const category = await this.categories.findById(dto.categoryId)
    if (!category) {
      throw new HttpException(400, `Category ${dto.categoryId} does not exist`)
    }

    // Verify the assignee exists, if one was provided
    if (dto.assigneeId) {
      const user = await this.users.findById(dto.assigneeId)
      if (!user) {
        throw new HttpException(400, `User ${dto.assigneeId} does not exist`)
      }
    }

    return this.tasks.create(dto)
  }
}
Enter fullscreen mode Exit fullscreen mode

A few things are happening here that I want to call out:

  1. createToken<T> — the repository tokens (USER_REPOSITORY, CATEGORY_REPOSITORY, TASK_REPOSITORY) are typed DI tokens, not plain symbols. When you @Inject(USER_REPOSITORY), TypeScript knows the result is IUserRepository — no casts, no generic parameter needed.

  2. No manual wiring — you never instantiated UserRepository, CategoryRepository, or TaskRepository. The DI container resolved them automatically through the register() methods in each module's index.ts. This is one of the nicer payoffs of the module pattern.

  3. HttpException — throwing this inside a use case gets caught by the framework's error handler and returned as a proper JSON error response with the status code you gave it. You don't need try/catch blocks in the controller.


Step 5: The Assignment Endpoint

Creating and updating tasks are already handled by the scaffold, but assignment is special enough that I want it on its own endpoint. We'll add a PATCH /tasks/:id/assign route that takes { assigneeId: string | null } and either assigns or unassigns.

First, a DTO:

// src/modules/tasks/application/dtos/assign-task.dto.ts
import { z } from 'zod'

export const assignTaskSchema = z.object({
  assigneeId: z.string().uuid().nullable(),
})

export type AssignTaskDTO = z.infer<typeof assignTaskSchema>
Enter fullscreen mode Exit fullscreen mode

Notice the .nullable() — passing null explicitly is how the client says "unassign this task", which is different from omitting the field entirely.

Then a use case:

// src/modules/tasks/application/use-cases/assign-task.use-case.ts
import { Service, Inject, HttpException } from '@forinda/kickjs'
import { TASK_REPOSITORY, type ITaskRepository } from '../../domain/repositories/task.repository'
import { USER_REPOSITORY, type IUserRepository } from '../../../users/domain/repositories/user.repository'
import type { AssignTaskDTO } from '../dtos/assign-task.dto'

@Service()
export class AssignTaskUseCase {
  constructor(
    @Inject(TASK_REPOSITORY) private readonly tasks: ITaskRepository,
    @Inject(USER_REPOSITORY) private readonly users: IUserRepository,
  ) {}

  async execute(taskId: string, dto: AssignTaskDTO) {
    const task = await this.tasks.findById(taskId)
    if (!task) {
      throw new HttpException(404, `Task ${taskId} not found`)
    }

    // null = unassign
    if (dto.assigneeId === null) {
      return this.tasks.update(taskId, { assigneeId: null } as any)
    }

    // Otherwise, verify the user exists
    const user = await this.users.findById(dto.assigneeId)
    if (!user) {
      throw new HttpException(400, `User ${dto.assigneeId} does not exist`)
    }

    return this.tasks.update(taskId, { assigneeId: dto.assigneeId } as any)
  }
}
Enter fullscreen mode Exit fullscreen mode

And finally, wire it into the task controller. Add an import, an @Autowired property, and a new @Patch method:

// src/modules/tasks/presentation/task.controller.ts
import { Controller, Get, Post, Put, Patch, Delete, Autowired, type Ctx } from '@forinda/kickjs'
// ...existing imports...
import { AssignTaskUseCase } from '../application/use-cases/assign-task.use-case'
import { assignTaskSchema } from '../application/dtos/assign-task.dto'

@Controller()
export class TaskController {
  // ...existing @Autowired properties...
  @Autowired() private readonly assignTaskUseCase!: AssignTaskUseCase

  // ...existing methods...

  @Patch('/:id/assign', { body: assignTaskSchema, name: 'AssignTask' })
  @ApiTags('Task')
  async assign(ctx: Ctx<KickRoutes.TaskController['assign']>) {
    const result = await this.assignTaskUseCase.execute(ctx.params.id, ctx.body)
    ctx.json(result)
  }
}
Enter fullscreen mode Exit fullscreen mode

That's the entire assignment feature. 30 lines of code for validated FK checks, typed payloads, and proper error responses. The scaffold did the boring 90%.


Step 6: Filtering Tasks by Category and Assignee

Tasks have a categoryId and assigneeId, and we want clients to be able to filter tasks by either. Remember that USER_QUERY_CONFIG object the scaffold generated? It has the same structure for tasks, and it already knows about every field on the task entity. Open src/modules/tasks/constants.ts:

import type { ApiQueryParamsConfig } from '@forinda/kickjs'

export const TASK_QUERY_CONFIG: ApiQueryParamsConfig = {
  filterable: ['title', 'description', 'status', 'priority', 'dueDate', 'categoryId', 'assigneeId'],
  sortable: ['title', 'status', 'priority', 'dueDate', 'createdAt', 'updatedAt'],
  searchable: ['title', 'description'],
}
Enter fullscreen mode Exit fullscreen mode

Done. Clients can now hit:

GET /api/v1/tasks?filter[status]=in_progress&filter[assigneeId]=<uuid>&sort=-priority
Enter fullscreen mode Exit fullscreen mode

...and get all in-progress tasks assigned to that user, sorted by priority descending. The in-memory repository already knows how to apply these filters because it implements findPaginated(parsed: ParsedQuery) and the scaffold gave it a working implementation. If you later switch to Prisma or Drizzle, the query config transfers unchanged — the adapters translate ParsedQuery into database queries automatically.


Step 7: Seeding Some Data

The in-memory repositories reset on every restart, which is great for testing but annoying for manual exploration. Let's add a tiny seeding helper that runs once at startup. Create src/seed.ts:

import { Container } from '@forinda/kickjs'
import { CreateUserUseCase } from './modules/users/application/use-cases/create-user.use-case'
import { CreateCategoryUseCase } from './modules/categories/application/use-cases/create-category.use-case'
import { CreateTaskUseCase } from './modules/tasks/application/use-cases/create-task.use-case'

export async function seed(): Promise<void> {
  const container = Container.getInstance()
  const createUser = container.resolve(CreateUserUseCase)
  const createCategory = container.resolve(CreateCategoryUseCase)
  const createTask = container.resolve(CreateTaskUseCase)

  const alice = await createUser.execute({ name: 'Alice', email: 'alice@example.com' })
  const bob = await createUser.execute({ name: 'Bob', email: 'bob@example.com' })

  const work = await createCategory.execute({
    name: 'Work',
    color: '#0ea5e9',
    description: 'Day job tasks',
  })
  const personal = await createCategory.execute({
    name: 'Personal',
    color: '#f59e0b',
  })

  await createTask.execute({
    title: 'Write the KickJS blog post',
    status: 'in_progress',
    priority: 'high',
    categoryId: work.id,
    assigneeId: alice.id,
  } as any)

  await createTask.execute({
    title: 'Buy groceries',
    status: 'todo',
    priority: 'low',
    categoryId: personal.id,
  } as any)
}
Enter fullscreen mode Exit fullscreen mode

Then call it from src/index.ts, but only in development:

import 'reflect-metadata'
import './config'
import { bootstrap } from '@forinda/kickjs'
import { modules } from './modules'
import { seed } from './seed'

export const app = await bootstrap({ modules })

if (process.env.NODE_ENV !== 'production') {
  await seed()
}
Enter fullscreen mode Exit fullscreen mode

Restart the server and hit GET /api/v1/tasks. You'll see the two seeded tasks. Hit GET /api/v1/tasks?filter[assigneeId]=<alice-id> and you'll see just Alice's task.


Step 8: Adding Swagger Docs (You Already Have Them)

The rest template installs @forinda/kickjs-swagger and pre-registers the SwaggerAdapter for you. If you open http://localhost:5173/docs in your browser, you'll see a fully populated Swagger UI with every endpoint grouped by the @ApiTags decorator on the controllers.

If you want to customize the metadata, open src/index.ts (or wherever the adapter is registered) and update the constructor options:

import { SwaggerAdapter } from '@forinda/kickjs-swagger'

export const app = await bootstrap({
  modules,
  adapters: [
    new SwaggerAdapter({
      info: {
        title: 'Task Management API',
        version: '1.0.0',
        description: 'REST API for managing tasks, categories, and user assignments',
      },
    }),
  ],
})
Enter fullscreen mode Exit fullscreen mode

The schemas come directly from your Zod DTOs — no manual JSDoc annotations, no separate OpenAPI YAML file to keep in sync. Add a field to createTaskSchema, restart, and Swagger reflects it immediately.


Step 9: Writing a Test

KickJS ships with a testing helper that spins up a real app without needing an HTTP port. Here's a test for the assignment endpoint:

// src/modules/tasks/__tests__/assign-task.test.ts
import 'reflect-metadata'
import { describe, it, expect, beforeEach } from 'vitest'
import request from 'supertest'
import { Container } from '@forinda/kickjs'
import { createTestApp } from '@forinda/kickjs-testing'
import { modules } from '../../..//modules'

describe('PATCH /tasks/:id/assign', () => {
  beforeEach(() => Container.reset())

  it('assigns a task to an existing user', async () => {
    const { expressApp } = await createTestApp({ modules })

    // Create a user
    const userRes = await request(expressApp)
      .post('/api/v1/users')
      .send({ name: 'Alice', email: 'alice@example.com' })
    const user = userRes.body

    // Create a category
    const catRes = await request(expressApp)
      .post('/api/v1/categories')
      .send({ name: 'Work', color: '#0ea5e9' })
    const category = catRes.body

    // Create an unassigned task
    const taskRes = await request(expressApp)
      .post('/api/v1/tasks')
      .send({
        title: 'Write tests',
        status: 'todo',
        priority: 'high',
        categoryId: category.id,
      })
    const task = taskRes.body

    // Assign it
    const assignRes = await request(expressApp)
      .patch(`/api/v1/tasks/${task.id}/assign`)
      .send({ assigneeId: user.id })

    expect(assignRes.status).toBe(200)
    expect(assignRes.body.assigneeId).toBe(user.id)
  })

  it('rejects assignment to a non-existent user', async () => {
    const { expressApp } = await createTestApp({ modules })

    const catRes = await request(expressApp)
      .post('/api/v1/categories')
      .send({ name: 'Work', color: '#0ea5e9' })

    const taskRes = await request(expressApp)
      .post('/api/v1/tasks')
      .send({
        title: 'Write tests',
        status: 'todo',
        priority: 'high',
        categoryId: catRes.body.id,
      })

    const res = await request(expressApp)
      .patch(`/api/v1/tasks/${taskRes.body.id}/assign`)
      .send({ assigneeId: '00000000-0000-0000-0000-000000000000' })

    expect(res.status).toBe(400)
    expect(res.body.message).toMatch(/does not exist/)
  })
})
Enter fullscreen mode Exit fullscreen mode

The Container.reset() call in beforeEach is important: KickJS decorators register classes on a global DI container at import time, so tests need to wipe it between runs to avoid leaking state. If you forget it, you'll get mysterious "already registered" errors when modules are re-imported.

Run the tests:

pnpm test
Enter fullscreen mode Exit fullscreen mode

Both should pass. Green, green, ship it.


Swapping In a Real Database

The in-memory repository was perfect for getting the app running without configuration, but you probably want actual persistence. Switching is a two-step process, and — importantly — none of your controller, service, or use-case code has to change.

Option 1: Prisma

pnpm kick add prisma
npx prisma init
# Edit prisma/schema.prisma to define User, Category, Task models
npx prisma migrate dev --name init
Enter fullscreen mode Exit fullscreen mode

Then regenerate the modules with --repo prisma:

pnpm kick g module user --repo prisma --force
pnpm kick g module category --repo prisma --force
pnpm kick g module task --repo prisma --force
Enter fullscreen mode Exit fullscreen mode

(Or, more surgically, just replace the in-memory-*.repository.ts files with Prisma equivalents — the CLI has templates for both.)

Option 2: Drizzle

pnpm kick add drizzle
Enter fullscreen mode Exit fullscreen mode

Then the same --repo drizzle flag on the generator. The repository interface stays the same, the implementation changes. Your use cases don't know or care which storage backend is in use.


What You Built

Let's take stock of what's running right now:

  • 3 modules (users, categories, tasks) with full CRUD
  • Foreign-key validation between tasks and both users and categories
  • A dedicated assignment endpoint with null-aware unassignment
  • Filtering, sorting, pagination, and text search on every list endpoint — for free
  • Auto-generated OpenAPI docs at /docs
  • Typed request/response contracts, end-to-end
  • Vitest test suite with createTestApp integration helpers
  • HMR-enabled dev server that preserves in-memory state across file changes

Total lines of handwritten code, not counting what the generators produced: around 100. The generators produced another 1,500 lines or so, but that's code you'd write yourself anyway — the kind of structural boilerplate that every well-organized backend ends up with. The point isn't that the generators make the code disappear; the point is that they make the code appear without you having to type it, and they produce the same shape every time, so your whole team reads it the same way.


Where to Go Next

A few directions to take this further, depending on where your project needs to grow:

  • Authentication. Run pnpm kick add auth to install the JWT/OAuth package. Add @Roles('admin') and @Public() decorators to your controllers — auth is middleware-based and composes cleanly with existing routes.
  • Background jobs. If task assignments should trigger notifications, add pnpm kick add queue:bullmq and define a @Job processor. The queue adapter handles Redis wiring, retries, and dead-letter queues.
  • WebSockets. Run pnpm kick add ws for live task updates. The WS adapter shares the same DI container as HTTP, so your services stay the same — you just add a @WsController.
  • Multi-tenancy. Add pnpm kick add multi-tenant if tasks should be scoped per organization. Tenant resolution becomes request middleware, and your repositories automatically filter.

Each of these is one command and a few imports away. The framework is built to let you add capabilities incrementally, not pick them all at the start.


Closing Thoughts

I wrote KickJS because I kept reaching for the same Express patterns — DI containers, decorators, Zod validation, code generators — and stitching them together by hand in every new project. The surprise for me was how much better the developer experience gets when those pieces are designed to work together from day one. The scaffold generator doesn't just create files; it creates the right files, with the right imports, wired up to the right DI tokens, using the right naming conventions. The type system flows from Zod schemas through to controller handlers without you lifting a finger. And because everything is still Express underneath, you never get stuck when you need to drop down to raw middleware.

If you built along with this article, you now have a working task management API with user assignment. If you want to see more KickJS in action, check out the full documentation or the example apps in the monorepo — there's a Jira clone, a GraphQL API, a microservice template, and more.

Happy building.


KickJS is an open-source framework. Find it on GitHub and npm. If it saves you time, a star means a lot.

Top comments (0)