Taskboard, Part 1: Your First Module
Over five parts you'll build Taskboard, a small team task tracker: projects, tasks, users, members and file attachments, on SQLite. This part scaffolds the app and adds a projects module that keeps its data in memory. By the end you can create and list projects over HTTP, and the tests pass.
The five parts
- Your first module — scaffold, the generated layout, a projects API in memory (this page).
- A database — kick/db on SQLite: schema, migrations, tasks, relational reads.
- Authentication — users, password hashing, sessions, protecting every route.
- Teams and permissions — members and roles, transactions, 403 vs 404.
- Attachments and shipping — file uploads, cleanup after commit, a production build.
Each part ends with a working, tested app, so you can stop at any of them.
You need Node.js ^22.18.0 || >=24.11.0 (for the dev server) and a package manager — the commands below use pnpm, but npm, yarn and bun work too.
Scaffold the app
pnpm dlx @forinda/kickjs-cli new taskboard --template rest --packages devtoolscd taskboardpnpm install--template rest sets up modules with a controller, service and repository each. --packages devtools adds the DevTools adapter, a dashboard for routes, requests and the DI container that you'll use below.
What you got
src/
index.ts # bootstrap(): modules, adapters, middleware
config/index.ts # the environment schema
modules/
index.ts # the list of modules the app mounts
hello/ # an example module — removed below
kick.config.ts # CLI settings: pattern, module folder, custom commands
.env # development values
.env.test # read instead of .env under vitestsrc/index.ts is the whole app in one call. It imports ./config first, so the environment schema is registered before anything reads a value, then boots:
// src/index.ts
export const app = await bootstrap({
modules,
runtime: expressRuntime(),
adapters: [DevToolsAdapter()],
middlewares: [
helmet(),
cors({ origin: '*' }),
requestId(),
requestLogger(),
// Express needs a body parser; Fastify and h3 parse bodies natively.
express.json(),
],
})The app runs on Express here; Fastify and h3 work the same way through runtime — see HTTP runtimes. src/config/index.ts declares PORT, NODE_ENV and LOG_LEVEL with Zod; you'll add to it in Part 3. .env.test is read instead of .env when tests run, with no fallback, so a test never quietly picks up a development value.
Start the dev server:
pnpm exec kick devIt serves on http://localhost:3000 and reloads on save. It also runs kick typegen on every change, which writes the route types your handlers use — more on that in a moment.
Generate the projects module
pnpm exec kick g module projectThe module name is pluralised for the folder and the URL, so this writes src/modules/projects/ and mounts it at /api/v1/projects:
src/modules/projects/
project.module.ts # registers the repository, declares the routes
project.controller.ts # HTTP: parse the request, call the service, return the result
project.service.ts # business logic
project.repository.ts # storage: factory, contract and DI token
project.constants.ts # which fields the list endpoint can filter, sort and search
dtos/ # request schemas and the response type
__tests__/ # controller and repository testsIt also adds .mount(ProjectModule()) to src/modules/index.ts. Each layer has one job, so the controller never touches storage and the repository never sees HTTP.
The controller
// src/modules/projects/project.controller.ts
@Controller()
export class ProjectController {
@Autowired() private readonly projectService!: ProjectService
@Get('/')
@ApiQueryParams(PROJECT_QUERY_CONFIG)
async list(ctx: Ctx<KickRoutes.ProjectController['list']>) {
return ctx.paginate((parsed) => this.projectService.findPaginated(parsed), PROJECT_QUERY_CONFIG)
}
@Get('/:id')
async getById(ctx: Ctx<KickRoutes.ProjectController['getById']>) {
const result = await this.projectService.findById(ctx.params.id)
if (!result) {
ctx.problem.notFound({ detail: `Project ${ctx.params.id} not found` })
return
}
return result
}
@Post('/', { body: createProjectSchema, name: 'CreateProject' })
async create(ctx: Ctx<KickRoutes.ProjectController['create']>) {
return reply.created(await this.projectService.create(ctx.body))
}
// update (PUT /:id) and remove (DELETE /:id) follow the same shape
}Three conventions to notice:
Ctx<KickRoutes.ProjectController['create']>typesctx.params,ctx.bodyandctx.queryfor that route.KickRoutesis generated by typegen from your decorators and schemas, soctx.bodyis exactly whatcreateProjectSchemaaccepts.- Handlers return their payload. The runtime sends it as JSON, and typegen reads the return type — that's what lets the typed client know what each route answers.
reply.created(...)andreply.noContent()carry a status other than 200. - Errors go through
ctx.problem, which answers with an RFC 9457problem+jsonbody and returns nothing, so the 404 stays out of the route's success type.
The repository and its contract
// src/modules/projects/project.repository.ts
export function createProjectRepository() {
const store = new Map<string, ProjectResponseDTO>()
return {
async findById(id: string): Promise<ProjectResponseDTO | null> {
return store.get(id) ?? null
},
async create(dto: CreateProjectDTO): Promise<ProjectResponseDTO> {
const now = new Date().toISOString()
const entity = {
id: randomUUID(),
...dto,
createdAt: now,
updatedAt: now,
} as ProjectResponseDTO
store.set(entity.id, entity)
return entity
},
// findAll, findPaginated, update, delete …
}
}
/** The contract, derived from the factory rather than declared beside it. */
export type ProjectRepository = ReturnType<typeof createProjectRepository>
export const PROJECT_REPOSITORY = createToken<ProjectRepository>('taskboard/Project/repository')The repository is a plain factory, and its type is the contract: ProjectRepository is whatever the factory returns, so the interface and the implementation can't drift apart. The module binds it to a token:
// src/modules/projects/project.module.ts
register(container) {
container.registerFactory(PROJECT_REPOSITORY, () => createProjectRepository())
},and the service asks for the token, not the implementation:
// src/modules/projects/project.service.ts
@Service()
export class ProjectService {
constructor(@Inject(PROJECT_REPOSITORY) private readonly repo: ProjectRepository) {}
// findById, findPaginated, create, update, delete — each delegates to this.repo
}This is the seam the rest of the tutorial leans on. In Part 2 you replace the body of createProjectRepository with database queries, and neither the service nor the controller changes. Tokens are covered in Dependency Injection.
DTOs and query config
The request schemas are Zod:
// src/modules/projects/dtos/create-project.dto.ts
export const createProjectSchema = z.object({
name: z.string().min(1, 'Name is required').max(200),
})
export type CreateProjectDTO = z.infer<typeof createProjectSchema>Passing it as @Post('/', { body: createProjectSchema }) validates every request before the handler runs. project.constants.ts lists which fields the list endpoint may filter, sort and search by — ctx.paginate reads ?page=, ?limit=, ?sort= and friends against it (Query Parsing):
export const PROJECT_QUERY_CONFIG: QueryFieldConfig = {
filterable: ['name'],
sortable: ['name', 'createdAt'],
searchable: ['name'],
}Remove the hello module
The scaffold's hello module was only there to prove the app boots. Remove it — this deletes src/modules/hello/ and takes its import and .mount(...) out of src/modules/index.ts:
pnpm exec kick rm module hellowhich leaves:
// src/modules/index.ts
import { defineModules } from '@forinda/kickjs'
import { ProjectModule } from './projects/project.module'
export const modules = defineModules().mount(ProjectModule())Try it
curl -X POST localhost:3000/api/v1/projects \
-H 'content-type: application/json' \
-d '{"name":"Launch"}'
# 201 {"id":"…","name":"Launch","createdAt":"…","updatedAt":"…"}
curl localhost:3000/api/v1/projects
# 200 {"data":[{"id":"…","name":"Launch",…}],"meta":{"page":1,"limit":…,"total":1,…}}
curl -X POST localhost:3000/api/v1/projects \
-H 'content-type: application/json' \
-d '{"name":""}'
# 422 {"status":422,"detail":"Name is required","errors":[{"field":"name","message":"Name is required"}],…}The invalid body never reaches your handler: validation answers 422 with the failing field.
Open http://localhost:3000/_debug in a browser. The DevTools dashboard lists every route with its method and path, the recent requests, and what's registered in the container — taskboard/Project/repository among them.
Restart the dev server and the project you created is gone: it lived in a Map. Part 2 fixes that.
Run the tests
pnpm testThe generated repository test exercises the in-memory store directly. The controller test boots the module with createTestApp and drives it with supertest:
const { app } = await createTestApp({ modules: [ProjectModule()] })
const res = await request(app.handle.bind(app)).get('/api/v1/projects')
expect(res.status).toBe(200)The other controller cases are generated as it.todo(...) — the reporter lists them as outstanding rather than counting them as passes. They're a checklist: fill them in as you change each endpoint. Testing covers createTestApp in full.
What you built
- A KickJS app on Express with DevTools.
- A
projectsmodule: controller, service, and a repository behind a DI token. - Request validation from a Zod schema, with
422problem responses. - Typed handlers from generated route types.
- Passing tests, plus a list of the ones still to write.
Next: Part 2 — A database, where the repository moves to SQLite and tasks arrive.