The app-minimal.js from the previous lesson fit in ten lines and started up with app.listen(3000). It works, but it cannot be tested, it cannot be shut down gracefully, it cannot be configured per environment and it has nowhere to grow. In this lesson we turn it into the real skeleton of Escena Viva: an application that is created in one place and started in another, with configuration centralized and validated, framework settings chosen deliberately, and a folder structure that can carry the six modules left in the course.
Contents
- The key separation: creating the application and starting it
src/app.js: thecreateApplication()factorysrc/server.js: startup- The Escena Viva folder structure
- Application settings with
app.setandapp.get - Per-environment configuration:
dotenvandsrc/config/index.js - Mounting the application on your own
httpserver - Graceful shutdown with
SIGTERM app.localsversusres.locals- A note about view engines
- The key separation: creating the application and starting it
A rule we are not going to break for the rest of the course:
One module creates the application and returns it. A different module makes it listen.
That is: src/app.js exports createApplication(), which builds the Express object with all its routes and middleware and returns it without calling listen. And src/server.js imports that function, creates the HTTP server and makes it listen.
It looks like a matter of style. It is not:
| Reason | What would happen if you mixed both |
|---|---|
| Testing (M9) | supertest needs the application not started: you hand it the app and it brings up an ephemeral server on a free port. If app.js calls listen, every test file would try to occupy port 3000 and the tests would fail in parallel. |
| Graceful shutdown (M11) | To close properly you need the reference to the Server object, not to the app. You only have it if startup is explicit. |
| Reuse | You can mount several instances (API and metrics on different ports) or nest it with app.use('/api/v1', createApplication()). |
On top of that, createApplication() is a factory, not a directly exported object: it lets you pass options and guarantees that each test gets a clean application, with no shared state from the previous one.
src/app.js: the createApplication() factory
src/app.js: the createApplication() factoryThis is the first version. It will keep growing in 06-03 (routers), 06-04 (our own middleware), 06-05 (third-party middleware) and 06-07 (errors).
// src/app.js
const express = require('express');
const { configuration } = require('./config/index.js');
/**
* Builds the Escena Viva Express application.
* It starts no server: it only returns the configured application.
*/
function createApplication(options = {}) {
const { serveStaticFiles = true } = options;
const app = express();
// --- Framework settings (section 5) ---
app.disable('x-powered-by');
app.set('trust proxy', configuration.trustProxy);
app.set('case sensitive routing', true);
app.set('json spaces', configuration.isProduction ? 0 : 2);
// --- Values shared by the whole application (section 9) ---
app.locals.platformName = 'Escena Viva';
app.locals.version = require('../package.json').version;
// --- Built-in middleware: JSON body reading and static files ---
app.use(express.json({ limit: configuration.bodyLimit }));
if (serveStaticFiles) {
app.use(express.static(configuration.publicDir, { index: 'index.html' }));
}
// --- Routes (to be filled in in 06-03) ---
app.get('/health', (req, res) => {
const { platformName, version } = app.locals;
res.json({ status: 'ok', platform: platformName, version });
});
return app;
}
module.exports = { createApplication };Points worth calling out: there is no listen anywhere (that is the contract); the function takes options with default values, so createApplication() with no arguments still works; the configuration comes from a module, not from process.env scattered across the file (section 6); and diagnostics are not printed here, because app.js does not talk to the console, it only builds.
src/server.js: startup
src/server.js: startup// src/server.js
const http = require('node:http');
const { createApplication } = require('./app.js');
const { configuration } = require('./config/index.js');
/** Creates the HTTP server and makes it listen. */
function startServer() {
// We create the server by hand instead of using app.listen(...):
// that way we keep the Server reference so we can close it (sections 7 and 8).
const server = http.createServer(createApplication());
const { port, host, environment } = configuration;
server.listen(port, host, () => {
console.error(`[escena-viva] env=${environment} listening on http://${host}:${port}`);
});
registerGracefulShutdown(server);
return server;
}
module.exports = { startServer };
// The file is both a module and a script.
if (require.main === module) startServer();We will write the registerGracefulShutdown function in section 8. Update the package.json scripts too, because the entry point has changed since Module 4: "start": "node src/server.js" and "dev": "node --watch src/server.js".
- The Escena Viva folder structure
Express imposes no structure. This is ours, designed so that every file has a single reason to change.
escena-viva/
├── data/ # events.json, sales.csv (already existed)
├── public/ # index.html, styles.css, app.js (already existed)
├── src/
│ ├── app.js # createApplication()
│ ├── server.js # startup and shutdown
│ ├── config/ # index.js (NEW) + paths.js (already existed)
│ ├── routes/ # Express routers (06-03)
│ ├── controllers/ # handlers (req, res) (06-03)
│ ├── middleware/ # our own middleware (06-04)
│ ├── schemas/ # validation with zod (06-06)
│ ├── errors.js # error hierarchy (06-07)
│ ├── domain/ # Event, Session, SalesManager (already existed)
│ ├── services/ # currency-exchange.js and business logic
│ ├── catalog-data.js # JSON access (already existed)
│ ├── reports/ streams/ utils/ and server/ (the hand-written M4 one, for reference)
└── package.jsonResponsibilities, so nobody has any doubt about where each thing goes:
| Folder | Responsibility | What it must not contain |
|---|---|---|
routes/ |
Declare which URL calls which controller and which middleware protects it | Business logic |
controllers/ |
Translate HTTP into domain: read req, call a service, respond |
Capacity rules, file access |
services/ |
Use cases: orchestrate domain and data | req/res objects |
domain/ |
Escena Viva rules: capacity, statuses, prices | No HTTP, no Express |
middleware/ |
Cross-cutting concerns: logging, identifier, caching | Rules specific to one endpoint |
schemas/ and config/ |
Expected shape of the input; reading and validating the environment | Queries, responses or logic |
The rule that sums it all up: the domain does not import Express, and Express does not import fs. If a file in domain/ needs require('express'), something is in the wrong place.
A warning against over-engineering
This structure makes sense for an API that is going to grow over six more modules. For a three-endpoint project it is overkill. Signs that you are overdoing it: controllers that only call a service that only calls a repository that only does one line (three files for a trivial operation), empty folders "just in case", and interfaces and abstractions for a single implementation that will never have another.
Start with routes/ + controllers/ and pull out services/ when the logic is repeated in two controllers, not before. In Escena Viva we will indeed reach that point, because ticket sales are used both from the API and from the reports.
- Application settings with
app.set and app.get
app.set and app.getExpress keeps settings in an internal dictionary. app.set(name, value) writes, app.get(name) reads (careful: app.get with two arguments registers a GET route; with one, it reads a setting), and app.enable/app.disable are shortcuts for boolean values.
| Setting | Recommended value | What it does and why |
|---|---|---|
env |
process.env.NODE_ENV or development |
In production it enables the view cache and the error handler stops sending the stack trace (06-07) |
x-powered-by |
disabled | Express adds X-Powered-By: Express to every response: free information for anyone hunting for targets running a known version. It is not much of a defense, but it costs nothing |
trust proxy |
1 or the list of trusted proxies |
Behind Nginx or a load balancer, req.ip is the proxy's IP and req.protocol is http even if the client came over HTTPS. With this setting, Express reads X-Forwarded-For and X-Forwarded-Proto. Essential for the rate limiter in 06-05 and for the logging in Module 11 |
json spaces |
2 in development, 0 in production |
Indents the JSON from res.json(). Handy when debugging with curl, wasted bandwidth in production |
case sensitive routing |
true |
Without it, /Events and /events are the same route. Two different URLs returning the same thing hurts caching and SEO |
strict routing |
false |
With false, /events and /events/ are the same route (the friendly behavior). With true, they are different. Our normalizePath from Module 4 did exactly this by hand |
query parser |
'simple' or 'extended' |
How nested query strings are interpreted (?filter[venue]=Bóveda). 'simple' uses the core querystring; 'extended' uses qs and supports nesting |
From a handler you can read them with app.get('env') or app.get('trust proxy'); req.ip and req.protocol depend on the latter.
Careful with
trust proxy. Turn it on only if there really is a proxy in front. Exposed directly to the internet, anyone can forge their IP by sending anX-Forwarded-Forheader, and your rate limiter becomes useless. That is why in our configuration it is an environment value, not a constant.
- Per-environment configuration:
dotenv and src/config/index.js
dotenv and src/config/index.jsdotenv has been a dependency since Module 5. Its job is to load a .env file into process.env. Nothing else: it does not validate or convert types. We do that ourselves.
# .env (NOT committed to the repository; the repository holds .env.example)
NODE_ENV=development
PORT=3000
BODY_LIMIT=100kb
TRUST_PROXY=0
ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173// src/config/index.js
const path = require('node:path');
require('dotenv').config();
const { ROOT } = require('./paths.js');
// Typed readers: each one throws if the value does not meet expectations.
const optional = (name, fallback) => (process.env[name] ?? '').trim() || fallback;
function required(name) {
const value = optional(name, '');
if (value === '') throw new Error(`Missing required variable: ${name}`);
return value;
}
function integer(name, fallback, { min, max }) {
const number = Number.parseInt(optional(name, String(fallback)), 10);
if (!Number.isInteger(number) || number < min || number > max) {
throw new Error(`The ${name} variable must be an integer between ${min} and ${max}`);
}
return number;
}
const list = (n) => optional(n, '').split(',').map((e) => e.trim()).filter(Boolean);
const VALID_ENVIRONMENTS = ['development', 'test', 'production'];
function buildConfiguration() {
const environment = optional('NODE_ENV', 'development');
if (!VALID_ENVIRONMENTS.includes(environment)) {
throw new Error(`NODE_ENV must be one of: ${VALID_ENVIRONMENTS.join(', ')}`);
}
return Object.freeze({
environment,
isProduction: environment === 'production',
port: integer('PORT', 3000, { min: 1, max: 65535 }),
host: optional('HOST', '127.0.0.1'),
bodyLimit: optional('BODY_LIMIT', '100kb'),
trustProxy: integer('TRUST_PROXY', 0, { min: 0, max: 10 }),
allowedOrigins: list('ALLOWED_ORIGINS'),
publicDir: path.join(ROOT, 'public'),
// In production we demand a signing key; in development there is a filler value.
gatewayKey: environment === 'production'
? required('GATEWAY_KEY')
: optional('GATEWAY_KEY', 'development-key'),
});
}
let configuration;
try {
configuration = buildConfiguration();
} catch (error) {
console.error(`[configuration] ${error.message}`); // fail fast
process.exit(1);
}
module.exports = { configuration, VALID_ENVIRONMENTS };The ideas behind this file:
- A single point where
process.envis read. Searching for it in the project should give exactly one result: this file. That way you know at a glance what the application needs in order to start. - Fail fast. If the gateway key is missing in production, the process dies at startup with a clear message. The alternative —starting up and failing three hours later, on the first sale— is far worse.
- Correct types.
process.env.PORTis the string'3000'; the configuration exposes the number3000and avoids surprise comparisons. - A frozen object with
Object.freeze: nobody changes the configuration at runtime. - The
.envnever goes into the repository. What you version is.env.example; add.envto.gitignore.
In Module 11 we will extend this with managed secrets; for now, this is enough.
- Mounting the application on your own
http server
http serverIn 06-01 we saw that app is a handler function. Here we take advantage of it: we write http.createServer(app) instead of app.listen(...). What exactly do you gain?
| Advantage | What you will need it for |
|---|---|
You hold the Server reference |
Closing it with server.close() during graceful shutdown (section 8) |
| You can attach other protocols to the same port | Socket.IO in Module 12 does new Server(server) |
| You can tune timeouts | server.keepAliveTimeout, server.headersTimeout in Module 11 |
| You can listen to server events | server.on('error', ...) to catch EADDRINUSE with a decent message |
| You can have two servers with the same app | HTTP and HTTPS, or a separate metrics port |
// The classic error: the port is already taken.
server.on('error', (error) => {
if (error.code !== 'EADDRINUSE') throw error;
console.error(`[escena-viva] port ${configuration.port} is already in use`);
process.exit(1);
});
- Graceful shutdown with
SIGTERM
SIGTERMWhen an orchestrator (PM2, Docker, Kubernetes) wants to stop your process, it sends it the SIGTERM signal. If you do nothing, Node dies immediately and in-flight requests are cut in half: a buyer could see their browser hang right after submitting an order. Graceful shutdown means stopping the acceptance of new connections, finishing the ones in flight, closing resources and exiting.
// src/server.js (continued)
const GRACE_PERIOD_MS = 10_000;
function registerGracefulShutdown(server) {
let shuttingDown = false;
function closeAndExit(signal) {
if (shuttingDown) return; // a second signal does not duplicate the shutdown
shuttingDown = true;
console.error(`[escena-viva] received ${signal}, closing gracefully...`);
// 1. We stop accepting new connections; the in-flight ones finish.
server.close((error) => {
if (error) console.error('[escena-viva] error while closing:', error.message);
process.exit(error ? 1 : 0);
});
// 2. We close idle keep-alive connections so we don't wait for them to expire.
server.closeIdleConnections();
// 3. A safety net; unref keeps the timer from holding the process alive.
setTimeout(() => {
console.error('[escena-viva] forced shutdown after the grace period');
process.exit(1);
}, GRACE_PERIOD_MS).unref();
}
process.on('SIGTERM', () => closeAndExit('SIGTERM'));
process.on('SIGINT', () => closeAndExit('SIGINT')); // Ctrl+C in development
}Details people usually skip: the shuttingDown flag keeps pressing Ctrl+C twice from launching two simultaneous shutdowns; without closeIdleConnections() an idle keep-alive connection keeps the server alive until it expires and the shutdown drags on forever; and the timer with unref() guarantees that the process ends even if something gets stuck, without preventing it from ending sooner if all goes well. This is where, in Module 11, we will add closing the database and flushing pending logs.
app.locals versus res.locals
app.locals versus res.localsTwo stores with similar names and very different lifetimes.
app.locals |
res.locals |
|
|---|---|---|
| Scope | The whole application | A single request/response |
| Lifetime | From startup to shutdown | From when the request arrives until it is answered |
| Shared between users | Yes | No |
| Typical use | Platform name, version, constants | Request identifier, authenticated user, start timestamp |
// Globals: set once, in createApplication().
app.locals.venues = ['Teatro Almendra', 'Sala Boveda', 'Auditorio Ribera'];
// Per request: set by a middleware (06-04).
app.use((req, res, next) => {
res.locals.requestId = crypto.randomUUID();
next();
});A serious and frequent mistake: storing the current user's data in
app.locals. Since it is global, the second user would see the first user's data. Anything that depends on who is making the request goes inres.locals(or onreq, as we will do withreq.eventandreq.validatedData).
- A note about view engines
Express knows how to render templates on the server: app.set('view engine', 'ejs'), app.set('views', ...) and res.render('event', { event }). It is the classic way of generating HTML from Node and it is still valid for content-driven sites.
Escena Viva does not use it. Our front end is the public/ folder (HTML, CSS and an app.js that calls the API with fetch), and the server is a JSON API: the same API serves a web front end, a mobile app or an internal dashboard, and its responses are tested with supertest without parsing HTML. What we do keep is express.static, which serves those files and replaces your static.js from Module 4; we will take it apart in 06-04.
Common Mistakes and Tips
- Calling
listen()insideapp.js. It breaks the Module 9 tests and the Module 11 shutdown. If you see alistenoutsideserver.js, it is a bug. - Exporting the already-built app (
module.exports = app) instead of a factory. It works until a test needs a different configuration; then everything has to be refactored. process.envscattered across twenty files. Impossible to know what the application needs. Centralize it inconfig/index.js.- Confusing
app.get('name')withapp.get('/path', handler). One argument reads a setting; two register a route. - Enabling
trust proxywith no proxy. You are giving away the ability to forge the IP to anyone. - Committing
.envto the repository. Into.gitignorefrom day one; version.env.example. - Tip: run
node -e "require('./src/config/index.js')"in thecheckscript. If the configuration is invalid, you will find out in continuous integration and not in production.
Exercises
Exercise 1: the separation in practice
Create src/app.js and src/server.js with the structure from this lesson and prove that the separation works: write a third file try-app.js that imports createApplication(), mounts it on an http.createServer on port 0 (Node assigns a free one), makes a request to /health with fetch and closes the server. It must not touch src/server.js at any point.
Exercise 2: configuration that fails fast
Extend src/config/index.js with a DEFAULT_CURRENCY variable that only accepts EUR, USD or GBP, defaulting to EUR. Check that an invalid value prevents startup with a clear message and exit code 1.
Exercise 3: an observable shutdown
Add an in-flight request counter to the graceful shutdown: a middleware increments it on entry and decrements it on res.on('finish'). On receiving SIGTERM, the server must print to stderr how many requests were still alive. Test it with a slow endpoint (/slow, which takes 3 seconds) and by sending the signal while it is in flight.
Solutions
Solution 1
// try-app.js
const http = require('node:http');
const { createApplication } = require('./src/app.js');
async function main() {
const server = http.createServer(createApplication({ serveStaticFiles: false }));
// Port 0: the operating system assigns a free one. This is what supertest does.
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const response = await fetch(`http://127.0.0.1:${server.address().port}/health`);
console.log(JSON.stringify({ status: response.status, body: await response.json() }));
await new Promise((resolve) => server.close(resolve));
}
main().catch((error) => {
console.error('[try-app]', error.message);
process.exitCode = 1;
});That this is possible without starting src/server.js is exactly the proof that the separation is done right.
Solution 2
// Inside src/config/index.js
const VALID_CURRENCIES = ['EUR', 'USD', 'GBP'];
function currency(name, fallback) {
const value = optional(name, fallback).toUpperCase();
if (!VALID_CURRENCIES.includes(value)) {
throw new Error(`${name} must be one of: ${VALID_CURRENCIES.join(', ')}`);
}
return value;
}
// ...inside buildConfiguration(): defaultCurrency: currency('DEFAULT_CURRENCY', 'EUR'),DEFAULT_CURRENCY=YEN node src/server.js
# [configuration] DEFAULT_CURRENCY must be one of: EUR, USD, GBP (exit: 1)Solution 3
// src/server.js — fragments with the counter
let inFlight = 0;
// Registered FIRST so it counts absolutely everything.
app.use((req, res, next) => {
inFlight += 1;
res.on('finish', () => { inFlight -= 1; });
next();
});
app.get('/slow', (req, res) => setTimeout(() => res.json({ status: 'ok' }), 3000));
process.on('SIGTERM', () => {
console.error(`[escena-viva] SIGTERM with ${inFlight} requests in flight`);
server.close(() => process.exit(0));
server.closeIdleConnections();
});An important detail: for the counter to count, it has to be registered before the routes. That is the central theme of 06-04.
Conclusion
You no longer have a toy script, but the skeleton of a real application. The central piece is the separation between createApplication() —which builds and returns the app without starting it— and src/server.js —which creates the http server, makes it listen and knows how to shut it down gracefully when SIGTERM arrives. That separation is not cosmetic: it is what will make it possible to test with supertest in Module 9, attach Socket.IO in Module 12 and deploy with PM2 and Docker in Module 11. You have also defined the Escena Viva folder structure with clear responsibilities (and you know when not to apply it), you have deliberately chosen the framework settings (x-powered-by off, trust proxy per environment, case sensitive routing on) and you have centralized configuration in a module that reads process.env exactly once, validates types and ranges and fails fast if something does not add up.
The app, however, has a single route: /health. In the next lesson, Routing in Express, we fill it up: route parameters compared with the compilePattern you wrote by hand, express.Router() to mount /api/events, /api/sessions and /api/orders as independent modules, router.param() to load the event only once, and the rule that prevents the most headaches: the order in which routes are declared.
Node.js Course: From Beginner to Advanced
Module 1: Introduction to Node.js
- What Is Node.js?
- Installing and Setting Up the Environment
- Your First Node.js Program
- The Node.js REPL
- Modern JavaScript for Node.js
- The Course Project: the Escena Viva Platform
Module 2: Core Concepts
- Node.js Architecture
- The Event Loop
- Callbacks and Asynchronous Programming
- Promises and async/await
- Events and EventEmitter
- CommonJS Modules and require()
- ES Modules and Interoperability
Module 3: File System and I/O
- Reading and Writing Files
- The fs Module in Depth
- Cross-Platform Paths with the path Module
- Working with Streams
- Transform Streams and pipeline
- Buffers and Binary Data
Module 4: HTTP and Web Servers
- Creating a Simple HTTP Server
- Handling Requests and Responses
- Manual Routing
- Serving Static Files
- Receiving Data: Request Bodies and JSON
- Consuming External APIs from Node.js
Module 5: NPM and Package Management
- Introduction to NPM and package.json
- Installing and Using Packages
- Semantic Versioning and package-lock
- npm Scripts and Project Automation
- Creating and Publishing Packages
- Dependency Security and Maintenance
Module 6: The Express.js Framework
- Introduction to Express.js
- Setting Up an Express Application
- Routing in Express
- Middleware
- Essential Third-Party Middleware
- Input Data Validation
- Error Handling
Module 7: Databases and ORMs
- Introduction to Databases
- Using MongoDB with Mongoose
- CRUD Operations
- Relationships, Population and Advanced Queries
- Using SQL Databases with Sequelize
- Migrations, Transactions and Seed Data
Module 8: Authentication and Authorization
- Introduction to Authentication
- User Registration and Password Hashing
- Sessions and Cookies with Passport.js
- Authentication with JWT
- Role-Based Access Control
- API Security Best Practices
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with Mocha and Chai
- Test Doubles with Sinon
- Integration Testing
- Coverage and Test Automation
- Debugging Node.js Applications
Module 10: Advanced Topics
- The Cluster Module
- Worker Threads
- Caching and Job Queues with Redis
- Performance Optimization
- Building RESTful APIs
- GraphQL with Node.js
Module 11: Deployment and DevOps
- Configuration and Environment Variables
- Logging and Monitoring in Production
- Using PM2 for Process Management
- Packaging with Docker
- Deploying to Heroku and Other PaaS
- Continuous Integration and Deployment
