Guide for adding a new REST endpoint to the TaskFlow backend (api/). Covers the Route → Controller → Service → Repository → Domain layered architecture, response shapes, HTTP status codes, and test strategy. NOTE: api/ is PLANNED — not yet implemented. This skill defines the conventions to follow when building it.
The
api/workspace is planned but not yet built. This skill defines the contracts and conventions to follow when it is. Source of truth for all API conventions:api/AGENTS.md. See also:references/api-conventions.md.
ALWAYS
{ data: ... } on success, { error: { code, message } } on failureapi/src/domain/ — must mirror ui/src/domain/task.tsnpm run typecheck && npm test from api/ before committingNEVER
any — fix the typeui/src/domain/task.ts to match the API — fix the API insteadRoute (api/src/routes/)
└─ HTTP only: parse params, call controller, send response
Controller (api/src/controllers/)
└─ Orchestration: validate input, call service, map to response
Service (api/src/services/)
└─ Business logic: rules, domain operations — no DB
Repository (api/src/repositories/)
└─ DB access only: Prisma calls — no business logic
Domain (api/src/domain/)
└─ Shared types — mirrors ui/src/domain/task.ts
See assets/endpoint-template.ts for a full working template.
GET /api/tasks → list all tasks
GET /api/tasks/:id → get one task
POST /api/tasks → create task
PATCH /api/tasks/:id → update task fields (partial)
DELETE /api/tasks/:id → delete task
PATCH /api/tasks/:id/status → transition task status
// Success (single)
{ "data": { "id": "...", "title": "..." } }
// Success (list)
{ "data": [...], "meta": { "total": 10 } }
// Error
{ "error": { "code": "TASK_NOT_FOUND", "message": "Task with id 123 was not found" } }
| Situation | Code |
|---|---|
| Success — read | 200 |
| Created | 201 |
| No content (DELETE) | 204 |
| Validation error | 400 |
| Not found | 404 |
| Conflict (invalid transition) | 409 |
| Server error | 500 |
Where does this logic belong?
→ HTTP parsing / routing? → routes/
→ Input validation? → controller (Zod schema)
→ Coordinating service calls? → controller
→ Business rules / domain ops? → service
→ Database queries (Prisma)? → repository
→ Types shared with UI? → domain/
Which test to write?
→ Full HTTP behavior? → Supertest integration on route
→ Business logic in isolation? → unit test on service (mock repo)
→ DB queries? → integration test with test DB
→ Type contracts? → TypeScript strict compile check
// api/src/routes/tasks.ts
import { Router } from 'express'
import { TasksController } from '../controllers/tasksController'
const router = Router()
const controller = new TasksController()
router.get('/', controller.getAll)
router.get('/:id', controller.getById)
router.post('/', controller.create)
router.patch('/:id', controller.update)
router.delete('/:id', controller.remove)
router.patch('/:id/status', controller.transitionStatus)
export default router
// api/src/controllers/tasksController.ts
import { Request, Response } from 'express'
import { z } from 'zod'
import { TasksService } from '../services/tasksService'
const TransitionSchema = z.object({
status: z.enum(['pending', 'in_progress', 'completed']),
})
export class TasksController {
constructor(private service = new TasksService()) {}
transitionStatus = async (req: Request, res: Response) => {
const parsed = TransitionSchema.safeParse(req.body)
if (!parsed.success) {
return res.status(400).json({
error: { code: 'VALIDATION_ERROR', message: parsed.error.message },
})
}
try {
const task = await this.service.transitionStatus(req.params.id, parsed.data.status)
res.json({ data: task })
} catch (err) {
if (err instanceof NotFoundError) return res.status(404).json({ error: err.toJSON() })
if (err instanceof ConflictError) return res.status(409).json({ error: err.toJSON() })
res.status(500).json({ error: { code: 'INTERNAL_ERROR', message: 'Unexpected error' } })
}
}
}
// api/src/services/tasksService.ts
import { canTransition } from '../domain/task'
import { NotFoundError, ConflictError } from '../errors'
import type { Task, TaskStatus } from '../domain/task'
export class TasksService {
constructor(private repo: TasksRepository) {}
async transitionStatus(id: string, newStatus: TaskStatus): Promise<Task> {
const task = await this.repo.findById(id)
if (!task) throw new NotFoundError('TASK_NOT_FOUND', `Task ${id} not found`)
if (!canTransition(task.status, newStatus)) {
throw new ConflictError('INVALID_TRANSITION', `Cannot go from ${task.status} → ${newStatus}`)
}
return this.repo.update(id, { status: newStatus })
}
}
# From api/
npm run dev # API server at localhost:3000
npm run typecheck # 0 errors required
npm test # all green required
# Smoke test
curl http://localhost:3000/api/tasks
curl -X PATCH http://localhost:3000/api/tasks/<id>/status \
-H "Content-Type: application/json" \
-d '{"status":"in_progress"}'
Before committing a new endpoint:
ui/src/domain/task.ts{ data: ... } or { error: { code, message } }npm run typecheck passes — 0 errorsnpm test passes — all greenany in new code../typescript/SKILL.md — Strict TypeScript, no any../zod-4/SKILL.md — Zod 4 for request validation../taskflow-integrate-api/SKILL.md — wire UI seam to this endpoint../taskflow-commit/SKILL.md — commit conventions../taskflow-new-feature/SKILL.md — UI counterpart