Hai un server Node.js che cresce e il file index.js è diventato un mostro da 500 righe. Le rotte si accavallano, i middleware si pestano i piedi, e quando arriva un errore 500 lo scopri perché il cliente ti manda uno screenshot del browser. Suona familiare?
Noi, di Meteora Web, abbiamo costruito decine di backend con Express — e abbiamo visto esattamente questo scenario. Il problema non è Express, che resta eccellente. È l'assenza di una struttura pensata prima di scrivere la prima rotta. Routing, middleware ed error handling non sono dettagli: sono l'architettura portante del tuo backend. Se li tratti come tali, il tuo codice rimane manutenibile, testabile e — soprattutto — non ti svegli alle 3 di notte per un bug in produzione.
Questa guida parte da un progetto concreto. Non teoria astratta: struttura delle cartelle, pattern di routing, middleware personalizzati, error handling centralizzato e un sistema che funziona dalla prima riga.
Perché la struttura del progetto non è un optional
Quando inizi un progetto Express, la tentazione è una sola: npm init, installi express, e butti tutto in app.js. Funziona finché hai tre rotte. Poi ne hai trenta, e capisci che il caos non è un bug: è un design che non esisteva.
Da contabili prima che ingegneri, ragioniamo così: ogni ora spesa in refactoring è un costo che potevi evitare. Una struttura solida è un investimento che paga subito.
La struttura che usiamo nei progetti reali
project-root/
├── src/
│ ├── app.js # Configurazione Express
│ ├── server.js # Avvio del server
│ ├── routes/ # Definizione delle rotte
│ │ ├── index.js # Router principale
│ │ ├── auth.routes.js
│ │ └── users.routes.js
│ ├── controllers/ # Logica delle route
│ │ ├── auth.controller.js
│ │ └── users.controller.js
│ ├── middleware/ # Middleware custom
│ │ ├── errorHandler.js
│ │ ├── auth.middleware.js
│ │ └── validate.middleware.js
│ ├── models/ # Schemi dati (Mongoose, Sequelize, etc.)
│ ├── services/ # Business logic (opzionale)
│ ├── utils/ # Funzioni di utilità
│ └── config/ # Configurazioni (env, db, etc.)
├── tests/
├── .env
└── package.json
Cosa cambia rispetto al monolite? Ogni file ha un solo compito. routes/ si occupa solo di definire quali URL esistono. controllers/ contiene la logica di risposta. I middleware sono separati e riutilizzabili. Mai più una funzione di autenticazione copiata in dieci posti.
Azione immediata: Crea questa struttura nel tuo progetto Express. Anche se è vuoto, il setup ti obbliga a pensare per moduli.
Routing avanzato: organizzare le rotte come un professionista
Express mette a disposizione express.Router(). Non è opzionale: è il modo corretto di segmentare le rotte. Usare il router ti permette di montare sotto-path, aggiungere middleware specifici per gruppo, e mantenere il file principale pulito.
Esempio pratico: rotte utenti
// src/routes/users.routes.js
const express = require('express');
const router = express.Router();
const usersController = require('../controllers/users.controller');
const authMiddleware = require('../middleware/auth.middleware');
const validateMiddleware = require('../middleware/validate.middleware');
router.get('/', usersController.getAll);
router.get('/:id', usersController.getById);
router.post('/', validateMiddleware.validateCreateUser, usersController.create);
router.put('/:id', authMiddleware.requireAdmin, usersController.update);
router.delete('/:id', authMiddleware.requireAdmin, usersController.delete);
module.exports = router;
E nel router principale:
// src/routes/index.js
const express = require('express');
const router = express.Router();
const authRoutes = require('./auth.routes');
const usersRoutes = require('./users.routes');
router.use('/auth', authRoutes);
router.use('/users', usersRoutes);
module.exports = router;
Poi in app.js monti tutto con una riga:
app.use('/api/v1', require('./routes'));
Vantaggio: se domani cambi la struttura da /api/v1/ a /api/v2/, modifichi solo quella riga. Non devi toccare nessuna rotta.
Parametri e query: gestione pulita
Non validare mai i parametri nel controller. Usa middleware di validazione dedicati. Un esempio con una funzione helper:
// src/middleware/validate.middleware.js
function validateCreateUser(req, res, next) {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({ error: 'Name and email are required' });
}
// Opzionale: validazione più strutturata (Joi, express-validator)
next();
}
module.exports = { validateCreateUser };
Azione immediata: Estrai ogni validazione in un middleware separato. Il tuo controller rimarrà pulito e potrai testare la validazione indipendentemente.
Middleware: la catena che decide tutto
Un middleware in Express è una funzione che riceve req, res, next. Può modificare la richiesta, terminare la risposta, o passare al successivo. Sbagliare l'ordine dei middleware è il secondo errore più comune (dopo averli messi tutti in un unico file).
Ordine corretto dei middleware
- Middleware globali (body-parser, cors, logging) — prima di tutto
- Middleware di autenticazione — se la rotta lo richiede
- Middleware di validazione — prima di chiamare il controller
- Controller — la logica finale
- Error handler — in fondo, per intercettare tutto
// src/app.js
const express = require('express');
const app = express();
// 1. Middleware globali
app.use(express.json());
app.use(cors());
app.use(morgan('dev'));
// 2. Rotta di health check (senza autenticazione)
app.get('/health', (req, res) => res.json({ status: 'ok' }));
// 3. Tutte le altre rotte
app.use('/api/v1', require('./routes'));
// 4. Error handler (deve essere l'ultimo)
app.use(require('./middleware/errorHandler'));
module.exports = app;
Middleware con parametri (factory)
Talvolta vuoi un middleware configurabile. Esempio: verifica ruolo utente con diversi livelli:
// src/middleware/requireRole.js
function requireRole(...roles) {
return (req, res, next) => {
if (!req.user || !roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
module.exports = requireRole;
Uso: router.delete('/:id', requireRole('admin', 'moderator'), controller.delete);
Azione immediata: Rivedi i tuoi middleware esistenti. Se hanno logica duplicata, trasformali in factory di middleware riutilizzabili.
Error handling centralizzato: non scoprire gli errori quando è tardi
Il pattern standard di Express per gli errori è try/catch in ogni controller e chiamare next(err). Ma se dimentichi un solo catch, l'applicazione crasha e il server muore. La soluzione è un error handler centrale e un wrapper per async.
Wrapper per async/await
// src/utils/asyncHandler.js
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
module.exports = asyncHandler;
Uso nel controller:
const asyncHandler = require('../utils/asyncHandler');
exports.getAll = asyncHandler(async (req, res) => {
const users = await User.find();
res.json(users);
});
Ora qualsiasi errore asincrono finisce automaticamente nell'error handler.
Error handler personalizzato
// src/middleware/errorHandler.js
function errorHandler(err, req, res, next) {
// Log dell'errore (in produzione potresti usare logger esterno)
console.error(err.stack);
// Errore personalizzato con status code
const statusCode = err.statusCode || 500;
const message = err.message || 'Internal Server Error';
res.status(statusCode).json({
success: false,
error: message,
// In dev, includi lo stack
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
});
}
module.exports = errorHandler;
E una classe per errori HTTP personalizzati:
// src/utils/HttpError.js
class HttpError extends Error {
constructor(statusCode, message) {
super(message);
this.statusCode = statusCode;
}
}
module.exports = HttpError;
Uso: throw new HttpError(404, 'User not found');
Azione immediata: Inserisci l'error handler nel tuo progetto. Se non hai un wrapper async, aggiungilo. Poi converti tutti i controller a usare il pattern try { ... } catch(next) o direttamente asyncHandler.
Messa in produzione: l'ultimo miglio
Non basta che il backend funzioni in locale. Devi assicurarti che la struttura pensata regga sotto carico e non esponga dettagli interni.
- Ambiente: usa
dotenve separa configurazioni per dev/staging/prod. - Logging: sostituisci
console.logcon un logger strutturato (winston, pino). - Rate limiting: proteggi le API pubbliche con
express-rate-limit. - Helmet: aggiungi header di sicurezza (
helmet). - CORS: configura solo i domini che devono accedere.
Noi, di Meteora Web, abbiamo visto progetti dove l'error handler restituiva stack trace completi in produzione. Risultato: un attaccante conosceva versione di Express e path delle librerie. Non farlo. Il nostro error handler di esempio già separa dev da prod.
Conclusione: cosa fare adesso
- Ristruttura il progetto secondo lo schema
routes/controllers/middleware. Anche se è un refactoring parziale, fallo. - Sposta tutte le rotte in router separati e montali con
express.Router(). - Centralizza l'error handling: crea
errorHandler.jse un wrapper async come mostrato. - Valida ogni input con middleware dedicati, non nei controller.
- Testa l'error handler: simula un errore e verifica che risponda con JSON pulito e senza stack in produzione.
Un backend con queste caratteristiche non è solo più ordinato: è più sicuro, più performante e molto più economico da mantenere nel tempo. E noi lo vediamo ogni giorno sui progetti dei nostri clienti.
Se vuoi approfondire, guarda la documentazione ufficiale di Express sull'error handling.
Sponsored Protocol