REST API for managing movies and directors. Node.js + TypeScript (Fastify), MongoDB via Mongoose, Redis for caching, Grafana/Loki for log aggregation, delivered as a Docker Compose stack.
docker compose up -d --build| Service | URL |
|---|---|
| API | http://localhost:3000/api |
| Swagger UI | http://localhost:3000/docs |
| Health | http://localhost:3000/health |
| Grafana | http://localhost:3001 (admin/admin) |
# Load sample data (3 directors, 5 movies)
docker compose exec app node dist/scripts/seed.js
curl http://localhost:3000/api/moviesThe code is organised in layers, each depending only on the one beneath it. A request travels downwards and data travels back up, converted at exactly one boundary per concern.
HTTP src/http/ routes β controllers β response envelope
β Route JSON Schemas validate input and serialize output.
βΌ Controllers hold no logic and no try/catch.
Service src/services/ business rules, caching, error semantics
β Decides what a "movie" means: which director is valid,
βΌ what makes a conflict, when the cache is stale.
Repository src/repositories/ data access + document β DTO mapping
β The only layer that speaks Mongoose. Scopes every read
βΌ to non-deleted records.
Database src/db/ typed Mongoose models and connection
Supporting modules:
| Path | Responsibility |
|---|---|
src/domain/ |
Zod schemas: one definition drives TypeScript types, runtime validation and Swagger docs |
src/cache/ |
Redis client and the namespaced cache facade |
src/config/ |
Validated environment, logger, CORS and Swagger setup |
src/shared/ |
Error hierarchy, pagination helpers, schema conversion |
src/scripts/ |
seed, cache:flush |
ops/ |
Loki, Promtail and Grafana configuration |
One schema per concept. Each domain entity is defined once as a Zod schema. The TypeScript type, the request validation and the OpenAPI documentation are all derived from it, so they cannot drift apart.
Movies reference directors by ID. Movie.director holds a Director UUID, and the service
rejects a write pointing at a director that does not exist (400). Deleting a director that movies
still reference is refused with 409 unless force=true is passed.
Errors are mapped in one place. Controllers let errors propagate. src/http/error-handler.ts
turns an AppError into its status code, a schema violation into 400 with field details, and
anything unexpected into a 500 whose message is masked, so internals never reach the client.
Writes invalidate, they do not patch. An earlier version kept the cached collection in sync by pushing and filtering entries in place. Two concurrent writers read-modify-write the same key and the loser's change disappears, so any write now drops the whole namespace instead. The cost is one extra database read; the benefit is that the cache cannot go stale.
The cache is optional. Every Redis operation degrades to a miss on failure. A Redis outage makes
the API slower, not unavailable. Startup continues without it and /health reports it as down.
buildApp() has no side effects. Constructing the application opens no connection and binds no
port, which lets the test suite drive the real server through app.inject(). src/server.ts owns
connections, listening and graceful shutdown.
Deletes are soft by default. DELETE sets isDeleted; ?force=true removes the document.
Repositories scope every read to isDeleted: false, so a soft deleted record is invisible above
that layer.
Full request/response documentation, including schemas and example payloads, is served at
/docs.
GET /api/movies?page=1&limit=20 # List movies (paginated)
GET /api/movies/:id # Movie details
POST /api/movies # Create a movie
PUT /api/movies/:id # Replace a movie
DELETE /api/movies/:id?force=false # Delete a movie
GET /api/directors?page=1&limit=20
GET /api/directors/:id
POST /api/directors
PUT /api/directors/:id
DELETE /api/directors/:id?force=false
GET /health # Dependency statusEvery response shares one shape, so clients branch on status rather than on HTTP status alone.
Collection endpoints wrap their results:
{ "items": [ /* ... */ ], "total": 42, "page": 1, "limit": 20, "totalPages": 3 }| Code | Meaning |
|---|---|
200 |
Success |
201 |
Created |
400 |
Schema validation failed, or a movie references a non-existent director |
404 |
Resource does not exist, or was already deleted |
409 |
Duplicate IMDb ID, or a director still referenced by movies |
500 |
Unexpected failure. Message masked, correlation id in x-request-id |
# Create a director, then a movie that references it
DIRECTOR_ID=$(curl -s -X POST http://localhost:3000/api/directors \
-H 'Content-Type: application/json' \
-d '{"firstName":"Denis","lastName":"Villeneuve","birthDate":"1967-10-03","bio":"Canadian filmmaker."}' \
| sed -n 's/.*"id":"\([^"]*\)".*/\1/p')
curl -X POST http://localhost:3000/api/movies \
-H 'Content-Type: application/json' \
-d "{\"title\":\"Dune\",\"description\":\"A noble family becomes embroiled in a war over the galaxy's most valuable asset.\",\"releaseDate\":\"2021-10-22\",\"genre\":\"Adventure, Drama, Sci-Fi\",\"rating\":8.0,\"imdbId\":\"tt1160419\",\"director\":\"$DIRECTOR_ID\"}"npm install
cp .env.example .env # every value has a working default
npm run dev # watch mode
npm run seed # sample data
npm run cache:flush # drop all cached entries
npm test # unit + endpoint tests
npm run test:coverage
npm run lint
npm run typecheckMongoDB and Redis are expected on localhost in development; docker compose up -d mongo redis
starts just those two.
| Suite | Scope |
|---|---|
src/__tests__/unit/ |
Service rules in isolation, repositories mocked |
src/__tests__/endpoints/ |
Full request lifecycle via app.inject(), only repositories mocked |
Endpoint tests exercise real routing, schema validation, serialization, caching and error mapping,
so a broken status code or envelope fails the build. Redis is replaced by an in-memory manual mock
(src/cache/__mocks__/redis.client.ts) that behaves like a real cache, which is what lets the suite
assert on hits, misses and invalidation without a running server.
All environment variables are declared and validated in src/config/env.ts. Startup fails with a
readable message if any value is invalid, rather than surfacing later as a NaN port.
| Variable | Default | Notes |
|---|---|---|
NODE_ENV |
development |
production switches logs to JSON stdout |
HOST / PORT |
0.0.0.0 / 3000 |
|
API_PREFIX |
/api |
|
MONGO_HOST |
localhost |
Compose overrides to mongo |
MONGO_PORT |
27017 |
|
MONGO_DATABASE |
movie-db |
|
REDIS_URL |
redis://localhost:6379 |
Compose overrides to redis://redis:6379 |
CACHE_TTL_SECONDS |
900 |
15 minutes |
LOG_LEVEL |
info |
|
LOG_PATH |
./logs/app.log |
Development only |
Every request is logged twice, on arrival and on completion, sharing one correlation id that is
also returned to the caller in the x-request-id header.
In production the application writes JSON to stdout. Promtail discovers containers through the
Docker API and ships the logs of any container carrying the logging.job Compose label, so no
shared log volume is needed and the API process runs unprivileged.
# Grafana β Explore β Loki
{job="movie-api"} # All application logs
{job="movie-api", level="error"} # Errors only
{job="movie-api"} |= "request completed"Loki runs multi-tenant, so requests need an X-Scope-OrgID header. It is provisioned in
ops/grafana-datasource.yml, but if the datasource was created before that file was mounted:
- Connections β Data sources β Loki
- Under HTTP β Custom HTTP Headers, add
X-Scope-OrgID: 1 - Save & Test
The image is built in two stages: TypeScript is compiled with the full dependency tree, then only
dist/ and production dependencies are copied into the runtime layer, which runs as the
unprivileged node user and declares a HEALTHCHECK against /health.
docker compose up -d --build # Start everything
docker compose logs -f app # Follow application logs
docker compose ps # Health status per service
docker compose down # Stop
docker compose down -v # Stop and drop volumesThe API waits for MongoDB and Redis to report healthy before starting, so a cold up does not
crash-loop while Mongo initialises.
MIT