A CRUD API for managing a to-do list, built with Node.js and Express as part of the FlyRank Backend Internship (Weeks 2–4, Assignments A1–A3 + layered architecture refactor).
Full Create, Read, Update, Delete on tasks, interactive Swagger docs, and two interchangeable storage backends — SQLite and containerized Postgres — running through a single, unified entry point. One DATABASE_TYPE setting decides which one runs; the routes, validation, and API behavior never change.
- Node.js + Express 5 — server and routing (Express 5 auto-forwards async errors, no manual try/catch needed in routes)
- better-sqlite3 — synchronous SQLite driver
- pg — PostgreSQL driver
- redis — Redis client, used for a real connectivity + health check (Postgres mode only)
- swagger-ui-express — interactive docs from a hand-written OpenAPI 3.0 spec (
openai.json) - cors — allows browser-based tools (e.g. Hoppscotch) to call the API from a different origin
- Docker + Docker Compose — containerized Postgres + Redis stack
├── index.js # single entry point — reads DATABASE_TYPE, wires the matching repo, starts the app
├── src/
│ ├── app.js # Express app factory — mounts routes + middleware, backend-agnostic
│ ├── errors.js # typed error classes (ValidationError, NotFoundError, ConflictError)
│ ├── middleware/
│ │ └── error-handler.js # central error → status code mapping
│ ├── routes/
│ │ ├── task.routes.js # /tasks routes — thin, delegate to services
│ │ └── meta.routes.js # /, /health
│ ├── services/
│ │ └── tasks.service.js # business logic — validation, duplicate checks, stats — shared by both backends
│ ├── repositories/
│ │ ├── taskRepository.js # SQLite data access
│ │ └── taskRepository.postgres.js # Postgres data access — same function signatures
│ └── db/
│ ├── db.sqlite.js # SQLite connection, table creation, seeding
│ ├── db.postgres.js # Postgres connection, table creation, seeding
│ └── db.redis.js # Redis client, connection, ping
├── Dockerfile # builds the Postgres app image
├── compose.yaml # api + db + redis services, one-command startup
├── tasks.db # SQLite file (gitignored)
├── .env / .env.example # DATABASE_TYPE, PORT, Postgres credentials
├── openai.json # OpenAPI spec powering /docs
├── package.json
└── README.md
git clone https://github.com/davidyassa/CRUD_API.git
cd CRUD_API
cp .env.example .envRequires: Node.js 18+
# in .env: DATABASE_TYPE=sqlite
npm install
npm starttasks.db is created and seeded automatically on first run — no database setup needed.
Requires: Docker Desktop running. No local Node.js install needed — the app runs entirely inside the container.
# in .env: DATABASE_TYPE=postgres
docker compose upCompose builds the app image, starts Postgres + Redis, creates the tasks table, seeds it, and starts the API — all in one command.
Either way, the API runs on http://localhost:3000 (set by PORT in .env) · Swagger docs at http://localhost:3000/docs.
Switching backends: change DATABASE_TYPE in .env, then restart with npm start (SQLite) or docker compose up (Postgres). Only one runs at a time — both use the same port.
Confirm Postgres persistence: create a task, then docker compose down followed by docker compose up again — the task is still there, because the named volume (taskdata) kept the data.
(optional) Browsing the Postgres database directly: connect a free GUI (TablePlus, DBeaver, pgAdmin) with:
| Field | Value |
|---|---|
| Host | localhost |
| Port | 5432 |
| User | postgres |
| Password | matches POSTGRES_PASSWORD in your .env |
| Database | matches POSTGRES_DB in your .env |
Open http://localhost:3000/docs. Click any endpoint to expand it, hit "Try it out", fill in the fields, click "Execute".
- Go to hoppscotch.io — no install, no account needed.
If requests fail with a network/CORS error, install the Hoppscotch browser extension — this app already sends CORS headers, but some browser setups still need it for
localhost. - Set the method dropdown to match the endpoint, paste the URL, e.g.
http://localhost:3000/tasks. - For
POST/PUT: click Body → JSON:
{ "title": "Buy milk" }- Click Send.
Quick things to try:
| Try this | Method + URL | Body |
|---|---|---|
| List all tasks | GET → http://localhost:3000/tasks |
— |
| Get one task | GET → http://localhost:3000/tasks/1 |
— |
| Create a task | POST → http://localhost:3000/tasks |
{ "title": "Walk the dog" } |
| Mark it done | PUT → http://localhost:3000/tasks/4 |
{ "done": true } |
| Delete it | DELETE → http://localhost:3000/tasks/4 |
— |
| Method | Path | Description |
|---|---|---|
| GET | / |
API description — name, active database type, available endpoints |
| GET | /health |
Reports API status; on Postgres also checks db (SELECT 1) and redis (PING) — 200/503 |
| GET | /tasks |
List all tasks — supports ?done=true|false and ?search=term |
| GET | /tasks/:id |
Get a single task by id |
| GET | /stats |
Task counts: total, completed, remaining |
| POST | /tasks |
Create a task ({ "title": "..." }) |
| POST | /reset |
SQLite only — clears and reseeds the table |
| PUT | /tasks/:id |
Update a task's title and/or done; if nothing actually changed, returns the task with an added message: "no change" field, still 200 |
| DELETE | /tasks/:id |
Delete a task |
| Code | Meaning |
|---|---|
| 200 | Successful read/update |
| 201 | Task created |
| 204 | Task deleted (empty body) |
| 400 | Invalid or missing input |
| 404 | Task with that id doesn't exist |
| 409 | A task with that title already exists (create/update) |
| 503 | /health reports a degraded dependency (Postgres mode) |
A layered structure keeps storage swappable without touching routes or business rules:
routes → services → repositories → database
- Routes (
src/routes/) are thin — they parse the request and call a service, nothing more. - Services (
src/services/tasks.service.js) own all business logic — validation, duplicate-title checks, not-found handling, stats aggregation. Written once, shared by both backends viaTaskServices(repo), a factory that takes whichever repositoryindex.jsinjects. - Repositories (
src/repositories/) are pure data access — no validation, no rules — just run a query and return the result.taskRepository.jsandtaskRepository.postgres.jsexpose identical function signatures, so this is the only layer that changes between backends. - Errors (
src/errors.js+src/middleware/error-handler.js) — typed error classes (ValidationError,NotFoundError,ConflictError) thrown from the service, caught in one central place and mapped to status codes. Express 5 auto-forwards thrown/rejected errors from async route handlers, so no per-route try/catch is needed.
index.js is the only file that knows both backends exist: it reads DATABASE_TYPE, requires the matching repository and db connection, and hands everything else the same app factory.
Postgres version note: pinned to postgres:16, not the default postgres (18+) tag — the 18+ image changed its data-directory layout in a way that's incompatible with a simple single-mount volume, which caused a startup error on the newer default.
Lean container image: the Dockerfile only installs the packages the app actually needs (express, pg, swagger-ui-express, dotenv, cors, redis) instead of the full package.json — this deliberately skips better-sqlite3, which needs native compilation tools not present in the lightweight node:22-alpine base image, and isn't needed in the Postgres container anyway.
Secrets: DATABASE_URL is never hardcoded — in Compose it's assembled from POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB, and PORT env vars (.env, gitignored; placeholder values committed to .env.example).
SQLite, via DB Browser for SQLite:
Postgres, via TablePlus:
Healthy state (Postgres mode):
{ "status": "ok", "db": "ok", "redis": "ok" }Degraded state — Redis stopped (docker compose stop redis), API stays up but reports it:
Run manually against the SQLite database in DB Browser:
DELETE FROM tasks WHERE done = 1;Clears completed tasks — a direct, visible confirmation that manual database changes take effect immediately and are reflected the next time the API reads from it, with no code change required.
- Query filtering —
GET /tasks?done=trueandGET /tasks?search=milk(combinable) - Duplicate-title check (
409) on bothPOST /tasksandPUT /tasks/:id - No-op update detection on
PUT /tasks/:id— flags when a request changes nothing - Layered architecture (routes/services/repositories/errors) — one service and one set of routes shared by both backends
- Real
/healthcheck on Postgres — pings the database (SELECT 1) and Redis (PING) rather than returning a static"ok"; returns503if either dependency is down - Redis added to the Docker Compose stack, connected on startup, included in the health check
- Single unified entry point (
index.js) and sharedPORT— switch backends via one.envvalue, no separate scripts or ports to remember
- No authentication (yet) — local development APIs, not production-hardened.
POST /resetis SQLite-only by design — the Postgres version exists specifically to demonstrate persistence, and a reset endpoint cuts against that story.- SQLite is single-writer — fine at this scale, exactly why the Postgres version exists.
- Only one backend runs at a time; switching requires changing
DATABASE_TYPEand restarting.



