133: Node.js Production Capstone — CLI, HTTP API, Express, Workers, Testing, Security, and Operations
Learning objectives
This capstone is the point where the separate Node.js topics have to work together. It verifies that you understand Node as a runtime, not only the syntax of Express route handlers. The implementation should give you something concrete to inspect when a request, worker, stream, or shutdown sequence does not behave as expected.
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.
The goal is not to build a complete enterprise platform. The goal is to make the boundaries explicit: input enters through a CLI or HTTP request, application code applies the rules, a repository owns storage, workers handle CPU-heavy work, and operational code controls startup and shutdown.
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. Keep that future change in mind when defining the repository contract, but do not introduce a database just to make this exercise look more production-like.
Architecture
The service should have a clear path from an external input to the code that performs the work. A request should not need to know whether records are held in memory, stored in a file, or eventually persisted in MongoDB.
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
The useful distinction here is between transport and application behavior. Express can parse an HTTP request and select a route, but it should not become the place where every business rule, persistence detail, and worker concern is mixed together. The CLI should be able to use the same service and repository contract in local mode, or the HTTP API in remote mode, without duplicating those rules.
Required file structure
This structure is one reasonable way to make ownership visible during review:
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. During debugging, you should be able to answer where configuration is validated, where authorization is decided, where a task is persisted, and where a worker is replaced after a crash. If those answers all point to one large server file, the boundaries are not doing enough work.
Configuration
Validate at startup:
PORT
APP_ENV
UPSTREAM_URL
REQUEST_BODY_LIMIT
REPORT_WORKERS
Configuration is an input boundary too. Read environment variables once, convert them to the types the application expects, and fail before the server begins accepting traffic when a value is invalid. A process that starts with an invalid port or an unusable upstream URL is harder to operate than a process that refuses to start with a clear error.
Secret values should not be printed. That includes startup diagnostics, error objects, request logs, and test output. It is fine to log which configuration keys were loaded, but do not log secret contents.
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;
}
This helper demonstrates the important behavior: an absent value can use a deliberate fallback, while a supplied value must be an integer within the allowed range. Apply the same discipline to the other settings, including URL validation for UPSTREAM_URL and sensible upper bounds for body size and worker count.
Repository contract
The domain should depend on a contract rather than on the storage mechanism. The in-memory implementation is enough for this capstone, but its methods should have the same shape and tenant-scoping behavior that a later MongoDB implementation will need.
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. This is not merely an optimization or a convenience for the current data structure. Tenant identity is part of the authorization boundary, so a lookup by task ID alone is unsafe: an ID supplied by one tenant must not reveal or modify another tenant's task.
MongoDB implementation later must preserve contract. In particular, changing repositories must not silently change pagination, version conflict, not-found, or tenant-isolation semantics at the HTTP layer.
Authentication stub
For capstone, you can use signed development API keys or a controlled auth adapter rather than implementing full OAuth. The purpose of this stub is to establish the boundary between authentication and authorization, not to recreate an identity provider.
Authentication establishes who the caller is. Authorization decides what that caller may do and which tenant's data the operation may access. Keep both decisions visible even when the development adapter is simple.
Example server context:
{
userId: 'u1',
tenantId: 'tenant-a',
permissions: new Set([
'task:read',
'task:create',
'task:update',
]),
}
Tests must include cross-tenant denial. Do not rely on the fact that the in-memory repository happens to contain only a few records; explicitly prove that a caller from tenant-a cannot read, update, or delete a task belonging to another tenant.
Task API contract
Keep the public contract independent of the framework details. Express is one implementation choice for these routes; clients should depend on the methods, paths, status codes, and response shapes instead.
GET /tasks
POST /tasks
GET /tasks/:taskId
PATCH /tasks/:taskId
DELETE /tasks/:taskId
POST /reports
GET /health/live
GET /health/ready
The liveness endpoint answers whether the process is running. Readiness answers whether it should receive traffic. That distinction becomes important during graceful shutdown and when dependencies affect whether the service can accept useful work.
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. A request containing an unexpected administrative or internal field should not be silently accepted. Strict input schemas make client mistakes visible and reduce the chance that a future field becomes an accidental privilege-escalation path.
PATCH with concurrency
A partial update still needs a concurrency rule. The client sends the version it last observed, and the service updates only if that version is still current.
{
"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."
}
}
This is optimistic concurrency control. The client is not promised that its stale update will overwrite a newer update. A 409 tells it to reload and make an intentional decision about the current record.
Pagination
Use cursor:
GET /tasks?limit=25&after=...
Stable order:
createdAt DESC, id DESC
The second sort key matters when two records have the same timestamp. Without a deterministic tie-breaker, records can move between pages or appear twice. Even in memory, design cursor so Mongo migration can preserve semantics. The cursor should encode the values needed to continue from the last stable position rather than exposing an implementation-specific array offset.
Request limits
JSON:
256 KB maximum
Report upload/stream endpoints get separate limits. A report input has different resource characteristics from a small task mutation, so do not let one broad limit accidentally make every endpoint accept large bodies.
Test 413. The test should send a request that crosses the configured boundary and verify both the status code and the safe public error behavior. Body size is a security and availability boundary, not just a user-experience detail.
Report worker
POST /reports accepts bounded dataset/parameters. Validate and bound those inputs before passing them to background work; moving unbounded input to a worker does not make the resource cost safe.
CPU-heavy summary calculation runs worker thread. Do not block main server. async does not make CPU-bound JavaScript yield to the event loop; a long synchronous calculation still prevents the process from handling other requests. A worker thread provides a separate execution context for that calculation.
For long report, return:
202 Accepted
with job ID. The response means the request was accepted for processing, not that the report is complete. Define how the job can be observed or retrieved within the scope of the capstone, and make failure states visible rather than leaving clients with an indefinitely pending assumption.
For course scope, worker pool can keep jobs in memory; document that this is not crash-durable. A process restart can lose queued and in-progress jobs. Later architecture can use external queue.
Worker pool rules
The worker pool is deliberately bounded. Unbounded concurrency turns load into memory pressure and makes shutdown unpredictable.
- fixed bounded worker count;
- max queue length;
- reject 503 when queue saturated;
- worker crash replacement;
- shutdown waits/cancels;
- metrics for queue depth/duration.
When the queue is full, return a distinct 503 outcome rather than accepting work that the service cannot reasonably schedule. If a worker crashes, replace it and make sure the job's failure or retry behavior is defined. During shutdown, stop accepting new work first, then wait for or cancel existing work within a deadline. Queue depth and job duration are the signals that tell you whether the pool is keeping up.
Upstream fetch
The metadata client should build URLs from a fixed, trusted base URL and a constrained category value:
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. Allowing a client to choose the destination can turn an ordinary metadata feature into a server-side request forgery risk, and it also makes timeouts, allowlists, and operational expectations much harder to control.
Timeout
Combine request/shutdown/upstream timeout signals where supported. A request that is cancelled by the client, a process that is shutting down, and an upstream deadline are different reasons for stopping work, but they all need to propagate to the operation where possible.
Every upstream request has deadline. A network call that has no deadline can hold resources far longer than the route's useful lifetime. Test timeout and verify the public error mapping, cleanup, and logging behavior rather than testing only that fetch rejects.
Request context
Use request ID:
response X-Request-Id
structured logs
upstream trace header if safe
The same identifier lets you connect a client response to server logs and, where it is safe and appropriate, to an upstream call. AsyncLocalStorage can make it available in deep logging. It should not become a reason to hide important dependencies: business functions should still receive explicit authorization inputs.
Logging
Example:
{
"level": "info",
"requestId": "r123",
"method": "POST",
"route": "/tasks",
"status": 201,
"durationMs": 18
}
This event contains enough operational context to find a request and measure its result without recording its sensitive payload. Never log auth secret/body blindly. Redaction should be deliberate, and error logging should preserve useful internal diagnostics without exposing secrets or stack details to the client.
Error mapping
Required public codes:
VALIDATION_ERROR
UNAUTHENTICATED
FORBIDDEN
NOT_FOUND
VERSION_CONFLICT
BODY_TOO_LARGE
UPSTREAM_UNAVAILABLE
REPORT_QUEUE_FULL
INTERNAL_ERROR
Map known domain and boundary failures to stable public codes and appropriate status codes. Keep internal exception details out of the response. Unexpected internal error gets 500 safe message. The logs may contain the request ID and a diagnostic stack trace subject to the application's logging policy, but the client should not receive implementation details.
Graceful shutdown
SIGTERM:
- readiness false;
- stop accepting;
- abort pollers/upstream work;
- stop worker queue accepting;
- wait bounded in-flight;
- close workers;
- close server;
- exit.
The order prevents new traffic from entering while existing work is being drained. Readiness must change early enough for an orchestrator or load balancer to stop routing new requests. Every wait needs a bound; graceful shutdown is not permission to hang forever on a stuck upstream call or worker.
Test in child process. A process-level test can verify that the signal reaches the application, readiness changes, the server stops accepting work, and the process exits within the intended grace period.
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. The CLI can translate command-line arguments and format output, but the task rules should remain in the shared application layer.
Exit codes are part of the CLI contract. Invalid input, an unavailable service, and an unexpected failure should not all look like successful execution to a shell script.
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. Streaming changes the memory behavior of the command: records can be produced and consumed incrementally, while pipeline also gives you a single place to handle errors and close the involved streams.
Test suite
Unit
Test the rules without requiring an HTTP server:
- title normalization;
- authorization policy;
- cursor encoding/decoding;
- version conflict;
- worker calculation.
HTTP integration
Exercise the transport boundary and its mapping to application behavior:
- auth;
- validation;
- body limit;
- CRUD;
- pagination;
- conflict;
- 404;
- safe 500;
- queue saturation.
Process
These tests cover behavior that unit and route tests cannot establish:
- startup invalid config;
- SIGTERM shutdown;
- CLI exit codes.
Security
Security tests should attack the boundaries directly:
- cross-tenant ID;
- unknown admin field;
- wrong content type;
- command/path injection where relevant;
- secret not logged.
For each failing test, inspect the layer where the unsafe behavior first becomes possible. A route test that passes while the repository accepts an unscoped ID is not evidence of tenant isolation; it may only mean the test fixture never tried the dangerous case.
Load/performance test
Measure:
- baseline request p50/p95/p99;
- CPU endpoint before worker;
- after worker;
- event-loop delay;
- worker queue saturation.
The before-and-after CPU comparison should make the effect of moving work off the main thread observable. Queue saturation is also a capacity result: a bounded queue may reject work by design, but you need evidence for where that boundary occurs. Document evidence, including the test conditions and the interpretation of the measurements.
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
This turns failure handling into something reviewable. For example, an upstream timeout should produce a bounded request, a safe public error, a useful correlated log, and no abandoned timer or worker resource. A client disconnect should not leave expensive work running indefinitely if the operation can safely be cancelled.
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
The deployment document is part of the implementation, not an afterthought. Someone operating the service should know how it starts, how it reports health, how long shutdown may take, and which jobs or records disappear when the process crashes. State the CPU and memory assumptions because worker count and request limits are resource decisions.
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?
These questions are meant to expose whether you understand the boundaries, not whether you can repeat framework vocabulary. A strong answer should identify the runtime behavior involved, the failure mode the boundary prevents, and where you would verify the claim in code or a test.
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.”
You do not need to know every API by memory. You do need to know which layer owns a problem, what evidence to inspect, and what assumptions must remain true when one implementation is replaced by another.
