133: Node.js Production Capstone — CLI, HTTP API, Express, Workers, Testing, Security, and Operations
Learning objectives
This capstone verifies that you understand Node as a runtime, not only Express syntax.
You will build a production-oriented Task Processing Service with:
- CLI;
- Express HTTP API;
- configuration;
- validation;
- authentication/authorization boundary;
- streams;
- worker thread job;
- outbound fetch;
- graceful shutdown;
- tests;
- observability;
- security controls.
MongoDB is intentionally not yet required. Persistence can use an in-memory repository or filesystem for the capstone baseline. The next module replaces repository with MongoDB.
Architecture
CLI / HTTP ↓ input validation ↓ application services ↓ repository abstraction ↓ in-memory/file implementation HTTP service ├─ auth middleware ├─ request context ├─ task routes ├─ report route └─ error boundary background └─ worker pool for CPU report calculation external └─ fetch upstream metadata API
Required file structure
src/ ├─ app/ │ ├─ create-app.js │ └─ lifecycle.js ├─ config/ │ └─ config.js ├─ domain/ │ ├─ errors.js │ ├─ task-service.js │ └─ task-policy.js ├─ http/ │ ├─ middleware/ │ │ ├─ auth.js │ │ ├─ request-id.js │ │ └─ error-handler.js │ └─ routes/ │ ├─ tasks.js │ └─ reports.js ├─ repositories/ │ └─ memory-task-repository.js ├─ workers/ │ ├─ report-worker.js │ └─ report-pool.js ├─ clients/ │ └─ metadata-client.js ├─ cli/ │ └─ task-cli.js └─ server.js
The exact names can differ, but ownership must be equally clear.
Configuration
Validate at startup:
PORT APP_ENV UPSTREAM_URL REQUEST_BODY_LIMIT REPORT_WORKERS
Secret values should not be printed.
Example:
function readInteger(name, fallback, { min, max }) {
const raw = process.env[name];
if (raw === undefined) {
return fallback;
}
const value = Number(raw);
if (
!Number.isInteger(value) ||
value < min ||
value > max
) {
throw new Error(`Invalid ${name}`);
}
return value;
}
Repository contract
export function createMemoryTaskRepository() {
const records = new Map();
return {
async insert(task) {
...
},
async findById({ taskId, tenantId }) {
...
},
async list({ tenantId, cursor, limit }) {
...
},
async update({ taskId, tenantId, patch, version }) {
...
},
async delete({ taskId, tenantId }) {
...
},
};
}
Every lookup is tenant-scoped.
MongoDB implementation later must preserve contract.
Authentication stub
For capstone, you can use signed development API keys or a controlled auth adapter rather than implementing full OAuth.
Example server context:
{
userId: 'u1',
tenantId: 'tenant-a',
permissions: new Set([
'task:read',
'task:create',
'task:update',
]),
}
Tests must include cross-tenant denial.
Task API contract
GET /tasks POST /tasks GET /tasks/:taskId PATCH /tasks/:taskId DELETE /tasks/:taskId POST /reports GET /health/live GET /health/ready
Create task
Request:
{
"title": "Generate monthly report",
"priority": "high"
}
Response 201:
{
"data": {
"task": {
"id": "t1",
"title": "Generate monthly report",
"priority": "high",
"completed": false,
"version": 1
}
}
}
Validate unknown fields.
PATCH with concurrency
{
"version": 1,
"title": "Updated report"
}
If stored version is 2:
409 Conflict
Response:
{
"error": {
"code": "VERSION_CONFLICT",
"message": "The task changed. Reload and try again."
}
}
Pagination
Use cursor:
GET /tasks?limit=25&after=...
Stable order:
createdAt DESC, id DESC
Even in memory, design cursor so Mongo migration can preserve semantics.
Request limits
JSON:
256 KB maximum
Report upload/stream endpoints get separate limits.
Test 413.
Report worker
POST /reports accepts bounded dataset/parameters.
CPU-heavy summary calculation runs worker thread.
Do not block main server.
For long report, return:
202 Accepted
with job ID.
For course scope, worker pool can keep jobs in memory; document that this is not crash-durable.
Later architecture can use external queue.
Worker pool rules
- fixed bounded worker count;
- max queue length;
- reject 503 when queue saturated;
- worker crash replacement;
- shutdown waits/cancels;
- metrics for queue depth/duration.
Upstream fetch
Metadata client:
export async function getCategoryMetadata(
category,
{
signal,
} = {},
) {
const url = new URL(
`/categories/${encodeURIComponent(category)}`,
config.upstreamUrl,
);
const response = await fetch(url, {
signal,
});
if (!response.ok) {
throw new UpstreamError(...);
}
return response.json();
}
Use fixed trusted base URL.
Do not accept arbitrary URL from client.
Timeout
Combine request/shutdown/upstream timeout signals where supported.
Every upstream request has deadline.
Test timeout.
Request context
Use request ID:
response X-Request-Id structured logs upstream trace header if safe
AsyncLocalStorage can make it available in deep logging.
Business functions should still receive explicit authorization inputs.
Logging
Example:
{
"level": "info",
"requestId": "r123",
"method": "POST",
"route": "/tasks",
"status": 201,
"durationMs": 18
}
Never log auth secret/body blindly.
Error mapping
Required public codes:
VALIDATION_ERROR UNAUTHENTICATED FORBIDDEN NOT_FOUND VERSION_CONFLICT BODY_TOO_LARGE UPSTREAM_UNAVAILABLE REPORT_QUEUE_FULL INTERNAL_ERROR
Unexpected internal error gets 500 safe message.
Graceful shutdown
SIGTERM:
- readiness false;
- stop accepting;
- abort pollers/upstream work;
- stop worker queue accepting;
- wait bounded in-flight;
- close workers;
- close server;
- exit.
Test in child process.
CLI
Commands:
task-cli list task-cli add "title" task-cli complete <id> task-cli export --output file.ndjson
CLI should call same service/repository contract where local mode, or HTTP API where remote mode depending your design.
Do not duplicate business validation.
Streaming export
task-cli export should stream NDJSON:
{"id":"t1","title":"A"}
{"id":"t2","title":"B"}
Use stream pipeline to file/stdout.
No full array buffering for large export.
Test suite
Unit
- title normalization;
- authorization policy;
- cursor encoding/decoding;
- version conflict;
- worker calculation.
HTTP integration
- auth;
- validation;
- body limit;
- CRUD;
- pagination;
- conflict;
- 404;
- safe 500;
- queue saturation.
Process
- startup invalid config;
- SIGTERM shutdown;
- CLI exit codes.
Security
- cross-tenant ID;
- unknown admin field;
- wrong content type;
- command/path injection where relevant;
- secret not logged.
Load/performance test
Measure:
- baseline request p50/p95/p99;
- CPU endpoint before worker;
- after worker;
- event-loop delay;
- worker queue saturation.
Document evidence.
Failure injection
Deliberately simulate:
upstream timeout worker crash queue full repository failure client disconnect SIGTERM during report malformed JSON oversized JSON cross-tenant request
For each, write:
HTTP/CLI result log result cleanup result retry behavior
Deployment document
Write:
Node version install command start command required env vars health endpoints SIGTERM grace period CPU/memory assumptions worker count log format known non-durable in-memory state
Review questions
- Which code is Node-specific versus Express-specific?
- Why is API contract independent of Express?
- Why is CPU report not fixed by
async? - Why is worker pool bounded?
- Why are task queries tenant-scoped?
- Why is CORS not authentication?
- Why does upstream fetch need timeout?
- Why is body size security?
- Why do tests use port 0?
- What would MongoDB replace without changing HTTP contract?
Exit criteria
You are ready for MongoDB only if you can explain:
JavaScript runtime modules/packages process/config event loop errors filesystem buffers/streams events HTTP/fetch API contracts Express security authentication workers/processes testing debugging production lifecycle
without describing everything as “Express handles it.”
