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)
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
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 aregraphql,ddd,cqrs, andminimal. -
--pm pnpm— uses pnpm.npmandyarnalso work. -
--repo inmemory— the generated repositories use an in-memory Map. For production you'd pickprismaordrizzle, but for a tutorial this gets us running without a database. (More on swapping repos later.) -
--git --install— initializes a git repo and runspnpm 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
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
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 })
That's it. No Express app construction, no manual middleware wiring, no app.listen — bootstrap() 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
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
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
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
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>
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()
}
}
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,
)
}
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'],
}
With this in place, a client can hit:
GET /api/v1/users?filter[name]=alice&sort=-createdAt&page=2&limit=10&search=example.com
...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
}
}
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
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>
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
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>
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)
}
}
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)
}
}
A few things are happening here that I want to call out:
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 isIUserRepository— no casts, no generic parameter needed.No manual wiring — you never instantiated
UserRepository,CategoryRepository, orTaskRepository. The DI container resolved them automatically through theregister()methods in each module'sindex.ts. This is one of the nicer payoffs of the module pattern.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>
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)
}
}
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)
}
}
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'],
}
Done. Clients can now hit:
GET /api/v1/tasks?filter[status]=in_progress&filter[assigneeId]=<uuid>&sort=-priority
...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)
}
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()
}
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',
},
}),
],
})
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/)
})
})
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
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
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
(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
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
createTestAppintegration 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 authto 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:bullmqand define a@Jobprocessor. The queue adapter handles Redis wiring, retries, and dead-letter queues. -
WebSockets. Run
pnpm kick add wsfor 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-tenantif 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)