Dependency Injection in Node.js con ExpressJS
La Dependency Injection (DI) è un pattern architetturale fondamentale nello sviluppo software moderno. Permette di separare la creazione delle dipendenze dalla loro utilizzazione, rendendo il codice più modulare, testabile e manutenibile. In questo articolo esploreremo come applicare questo pattern in modo efficace all'interno di un'applicazione Node.js basata su ExpressJS, partendo dai concetti fondamentali fino ad arrivare a implementazioni avanzate con container IoC.
Cos'è la Dependency Injection
Prima di entrare nel dettaglio dell'implementazione, è utile chiarire cosa si intende per dipendenza e come il pattern DI risolve i problemi legati alla gestione delle dipendenze.
Una dipendenza è qualsiasi oggetto o modulo di cui una classe o una funzione ha bisogno per svolgere il proprio lavoro. Ad esempio, un servizio che gestisce gli utenti potrebbe dipendere da un repository che accede al database, oppure da un servizio di hashing delle password.
Senza Dependency Injection, le dipendenze vengono create direttamente all'interno della classe che ne ha bisogno:
// Approccio senza Dependency Injection
class UserService {
constructor() {
// La dipendenza viene creata internamente: accoppiamento forte
this.userRepository = new UserRepository();
this.hashService = new HashService();
}
async createUser(userData) {
const hashedPassword = await this.hashService.hash(userData.password);
return this.userRepository.save({ ...userData, password: hashedPassword });
}
}
Questo approccio presenta diversi problemi: la classe è strettamente accoppiata alle sue dipendenze concrete, è impossibile sostituire le implementazioni senza modificare il codice, e scrivere unit test diventa complicato perché non è possibile iniettare mock o stub.
Con la Dependency Injection, le dipendenze vengono fornite dall'esterno:
// Approccio con Dependency Injection
class UserService {
constructor(userRepository, hashService) {
// Le dipendenze vengono ricevute dall'esterno: accoppiamento debole
this.userRepository = userRepository;
this.hashService = hashService;
}
async createUser(userData) {
const hashedPassword = await this.hashService.hash(userData.password);
return this.userRepository.save({ ...userData, password: hashedPassword });
}
}
Ora UserService non sa né si preoccupa di come vengono create le sue dipendenze. Riceve ciò di cui ha bisogno tramite il costruttore e si concentra esclusivamente sulla propria logica di business.
I vantaggi della Dependency Injection
L'adozione della DI porta con sé una serie di benefici concreti che migliorano la qualità del codice nel lungo periodo.
Testabilità: è possibile iniettare facilmente oggetti mock o stub durante i test, isolando il componente sotto test dalle sue dipendenze reali. Questo rende i test unitari veloci, affidabili e privi di effetti collaterali.
Manutenibilità: cambiare l'implementazione di una dipendenza (ad esempio passare da MongoDB a PostgreSQL) non richiede di modificare i servizi che la utilizzano, ma solo il punto in cui la dipendenza viene istanziata e iniettata.
Riusabilità: i componenti progettati per ricevere le proprie dipendenze dall'esterno sono più generici e riutilizzabili in contesti differenti.
Separazione delle responsabilità: ogni componente si occupa esclusivamente della propria logica, delegando la gestione del ciclo di vita delle dipendenze a un livello superiore.
Struttura del progetto
Per seguire l'articolo in modo pratico, organizzeremo il progetto secondo una struttura a strati ben definita:
project/
├── src/
│ ├── config/
│ │ └── database.js
│ ├── controllers/
│ │ └── userController.js
│ ├── repositories/
│ │ └── userRepository.js
│ ├── services/
│ │ ├── userService.js
│ │ └── hashService.js
│ ├── routes/
│ │ └── userRoutes.js
│ ├── container.js
│ └── app.js
├── tests/
│ └── userService.test.js
└── package.json
Questa struttura rispecchia il pattern a tre strati (controller, service, repository) che si sposa naturalmente con la Dependency Injection, rendendo esplicite le dipendenze tra i livelli.
Implementazione manuale della Dependency Injection
La forma più semplice di DI non richiede alcuna libreria esterna. È sufficiente creare le dipendenze manualmente e passarle ai componenti che ne hanno bisogno.
Iniziamo definendo il repository degli utenti, che gestisce l'accesso ai dati:
// src/repositories/userRepository.js
class UserRepository {
constructor(db) {
// Riceve la connessione al database come dipendenza
this.db = db;
}
async findById(id) {
return this.db.collection('users').findOne({ _id: id });
}
async findByEmail(email) {
return this.db.collection('users').findOne({ email });
}
async save(userData) {
const result = await this.db.collection('users').insertOne(userData);
return { ...userData, _id: result.insertedId };
}
async update(id, userData) {
await this.db.collection('users').updateOne({ _id: id }, { $set: userData });
return this.findById(id);
}
async delete(id) {
return this.db.collection('users').deleteOne({ _id: id });
}
}
module.exports = UserRepository;
Definiamo poi il servizio di hashing, responsabile della cifratura delle password:
// src/services/hashService.js
const bcrypt = require('bcrypt');
class HashService {
constructor(saltRounds = 10) {
// Il numero di round può essere configurato dall'esterno
this.saltRounds = saltRounds;
}
async hash(plainText) {
return bcrypt.hash(plainText, this.saltRounds);
}
async compare(plainText, hash) {
return bcrypt.compare(plainText, hash);
}
}
module.exports = HashService;
Ora definiamo il servizio utenti, che contiene la logica di business e dipende sia da UserRepository che da HashService:
// src/services/userService.js
class UserService {
constructor(userRepository, hashService) {
// Entrambe le dipendenze vengono iniettate nel costruttore
this.userRepository = userRepository;
this.hashService = hashService;
}
async registerUser({ name, email, password }) {
// Verifica che l'utente non esista già
const existingUser = await this.userRepository.findByEmail(email);
if (existingUser) {
throw new Error('Un utente con questa email esiste già');
}
// Cifra la password prima di salvarla
const hashedPassword = await this.hashService.hash(password);
return this.userRepository.save({
name,
email,
password: hashedPassword,
createdAt: new Date(),
});
}
async getUserById(id) {
const user = await this.userRepository.findById(id);
if (!user) {
throw new Error('Utente non trovato');
}
// Rimuove la password dalla risposta
const { password, ...safeUser } = user;
return safeUser;
}
async verifyCredentials(email, password) {
const user = await this.userRepository.findByEmail(email);
if (!user) {
throw new Error('Credenziali non valide');
}
const isValid = await this.hashService.compare(password, user.password);
if (!isValid) {
throw new Error('Credenziali non valide');
}
const { password: _, ...safeUser } = user;
return safeUser;
}
}
module.exports = UserService;
Definiamo il controller, che gestisce le richieste HTTP e delega la logica al servizio:
// src/controllers/userController.js
class UserController {
constructor(userService) {
// Riceve il servizio come dipendenza
this.userService = userService;
// Associa i metodi all'istanza per evitare problemi di contesto con Express
this.register = this.register.bind(this);
this.getUser = this.getUser.bind(this);
this.login = this.login.bind(this);
}
async register(req, res, next) {
try {
const user = await this.userService.registerUser(req.body);
res.status(201).json({ success: true, data: user });
} catch (error) {
next(error);
}
}
async getUser(req, res, next) {
try {
const user = await this.userService.getUserById(req.params.id);
res.json({ success: true, data: user });
} catch (error) {
next(error);
}
}
async login(req, res, next) {
try {
const { email, password } = req.body;
const user = await this.userService.verifyCredentials(email, password);
res.json({ success: true, data: user });
} catch (error) {
next(error);
}
}
}
module.exports = UserController;
Definiamo le route, che collegano gli endpoint HTTP ai metodi del controller:
// src/routes/userRoutes.js
const { Router } = require('express');
function createUserRouter(userController) {
// Il controller viene iniettato come argomento della factory function
const router = Router();
router.post('/register', userController.register);
router.post('/login', userController.login);
router.get('/:id', userController.getUser);
return router;
}
module.exports = createUserRouter;
Il composition root
Il composition root è il punto dell'applicazione in cui tutte le dipendenze vengono create e assemblate. È l'unico posto in cui è accettabile istanziare direttamente le classi concrete. Tutti gli altri moduli ricevono le proprie dipendenze iniettate dall'esterno.
// src/container.js
const { MongoClient } = require('mongodb');
const UserRepository = require('./repositories/userRepository');
const HashService = require('./services/hashService');
const UserService = require('./services/userService');
const UserController = require('./controllers/userController');
const createUserRouter = require('./routes/userRoutes');
async function buildContainer(config) {
// Inizializza la connessione al database
const client = new MongoClient(config.mongoUri);
await client.connect();
const db = client.db(config.dbName);
// Costruisce il grafo delle dipendenze dal basso verso l'alto
const hashService = new HashService(config.saltRounds);
const userRepository = new UserRepository(db);
const userService = new UserService(userRepository, hashService);
const userController = new UserController(userService);
const userRouter = createUserRouter(userController);
return {
userRouter,
// Espone il client per la gestione del ciclo di vita
async dispose() {
await client.close();
},
};
}
module.exports = buildContainer;
Infine, il file principale dell'applicazione utilizza il container per costruire l'app Express:
// src/app.js
const express = require('express');
const buildContainer = require('./container');
async function createApp(config) {
const app = express();
app.use(express.json());
// Costruisce il container e ottiene le dipendenze assemblate
const container = await buildContainer(config);
// Registra le route con i router già configurati
app.use('/api/users', container.userRouter);
// Middleware per la gestione centralizzata degli errori
app.use((error, req, res, next) => {
console.error(error.message);
res.status(error.status || 500).json({
success: false,
message: error.message || 'Errore interno del server',
});
});
return { app, container };
}
module.exports = createApp;
// src/index.js
const createApp = require('./app');
const config = {
mongoUri: process.env.MONGO_URI || 'mongodb://localhost:27017',
dbName: process.env.DB_NAME || 'myapp',
saltRounds: parseInt(process.env.SALT_ROUNDS || '10', 10),
port: parseInt(process.env.PORT || '3000', 10),
};
async function start() {
const { app, container } = await createApp(config);
const server = app.listen(config.port, () => {
console.log(`Server avviato sulla porta ${config.port}`);
});
// Gestisce lo spegnimento ordinato del server
process.on('SIGTERM', async () => {
server.close(async () => {
await container.dispose();
process.exit(0);
});
});
}
start().catch(console.error);
Testare con la Dependency Injection
Uno dei maggiori vantaggi della DI è la facilità con cui si possono scrivere test unitari. Poiché ogni componente riceve le proprie dipendenze dall'esterno, è sufficiente passare degli oggetti mock per isolare il componente dal resto del sistema.
// tests/userService.test.js
const UserService = require('../src/services/userService');
describe('UserService', () => {
let userService;
let mockUserRepository;
let mockHashService;
beforeEach(() => {
// Crea mock manuali delle dipendenze
mockUserRepository = {
findByEmail: jest.fn(),
findById: jest.fn(),
save: jest.fn(),
};
mockHashService = {
hash: jest.fn(),
compare: jest.fn(),
};
// Inietta i mock nel servizio da testare
userService = new UserService(mockUserRepository, mockHashService);
});
describe('registerUser', () => {
it('dovrebbe registrare un nuovo utente con password cifrata', async () => {
// Prepara i dati di input e i valori di ritorno attesi
const userData = { name: 'Mario Rossi', email: 'mario@example.com', password: 'secret123' };
const hashedPassword = 'hashed_secret123';
const savedUser = { _id: '123', ...userData, password: hashedPassword };
mockUserRepository.findByEmail.mockResolvedValue(null);
mockHashService.hash.mockResolvedValue(hashedPassword);
mockUserRepository.save.mockResolvedValue(savedUser);
const result = await userService.registerUser(userData);
// Verifica che le dipendenze siano state chiamate correttamente
expect(mockUserRepository.findByEmail).toHaveBeenCalledWith(userData.email);
expect(mockHashService.hash).toHaveBeenCalledWith(userData.password);
expect(mockUserRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ password: hashedPassword })
);
expect(result).toEqual(savedUser);
});
it('dovrebbe lanciare un errore se l\'email è già registrata', async () => {
const userData = { name: 'Mario Rossi', email: 'mario@example.com', password: 'secret123' };
// Simula un utente già esistente nel database
mockUserRepository.findByEmail.mockResolvedValue({ _id: '456', email: userData.email });
await expect(userService.registerUser(userData)).rejects.toThrow(
'Un utente con questa email esiste già'
);
// Verifica che il salvataggio non sia stato eseguito
expect(mockUserRepository.save).not.toHaveBeenCalled();
});
});
describe('verifyCredentials', () => {
it('dovrebbe restituire l\'utente senza password se le credenziali sono valide', async () => {
const email = 'mario@example.com';
const password = 'secret123';
const storedUser = { _id: '123', name: 'Mario Rossi', email, password: 'hashed_secret123' };
mockUserRepository.findByEmail.mockResolvedValue(storedUser);
mockHashService.compare.mockResolvedValue(true);
const result = await userService.verifyCredentials(email, password);
// Verifica che la password non sia presente nel risultato
expect(result).not.toHaveProperty('password');
expect(result).toEqual({ _id: '123', name: 'Mario Rossi', email });
});
});
});
Questi test sono rapidi, deterministici e non richiedono alcuna connessione a database o servizi esterni. L'intera suite può essere eseguita in pochi millisecondi.
Utilizzo di un container IoC con Awilix
Nelle applicazioni più complesse, gestire manualmente il grafo delle dipendenze può diventare oneroso. I container IoC (Inversion of Control) automatizzano questo processo, risolvendo le dipendenze in modo dichiarativo. Una delle librerie più diffuse nell'ecosistema Node.js per questo scopo è Awilix.
npm install awilix awilix-express
Awilix supporta tre modalità di registrazione delle dipendenze: asClass, asFunction e asValue. Supporta inoltre diversi cicli di vita: SINGLETON (una sola istanza per tutta l'applicazione), SCOPED (una istanza per richiesta HTTP) e TRANSIENT (una nuova istanza ogni volta che viene richiesta).
// src/container.js con Awilix
const { createContainer, asClass, asFunction, asValue, Lifetime } = require('awilix');
const { MongoClient } = require('mongodb');
const UserRepository = require('./repositories/userRepository');
const HashService = require('./services/hashService');
const UserService = require('./services/userService');
const UserController = require('./controllers/userController');
async function buildContainer(config) {
const container = createContainer();
// Connessione al database come valore singleton
const client = new MongoClient(config.mongoUri);
await client.connect();
const db = client.db(config.dbName);
container.register({
// Registra la connessione al database come valore statico
db: asValue(db),
// Registra la configurazione
saltRounds: asValue(config.saltRounds),
// Registra i servizi e i repository come classi singleton
userRepository: asClass(UserRepository, { lifetime: Lifetime.SINGLETON }),
hashService: asClass(HashService, { lifetime: Lifetime.SINGLETON }),
userService: asClass(UserService, { lifetime: Lifetime.SINGLETON }),
userController: asClass(UserController, { lifetime: Lifetime.SINGLETON }),
});
return container;
}
module.exports = buildContainer;
Awilix risolve le dipendenze in modo automatico tramite auto-wiring: i parametri del costruttore vengono abbinati ai nomi registrati nel container. Questo significa che le classi non hanno bisogno di modifiche: purché i nomi dei parametri del costruttore corrispondano ai nomi registrati nel container, tutto funziona automaticamente.
// src/app.js con Awilix
const express = require('express');
const buildContainer = require('./container');
async function createApp(config) {
const app = express();
app.use(express.json());
const container = await buildContainer(config);
// Recupera il controller dal container per configurare le route
const userController = container.resolve('userController');
app.post('/api/users/register', (req, res, next) => userController.register(req, res, next));
app.post('/api/users/login', (req, res, next) => userController.login(req, res, next));
app.get('/api/users/:id', (req, res, next) => userController.getUser(req, res, next));
app.use((error, req, res, next) => {
res.status(error.status || 500).json({
success: false,
message: error.message || 'Errore interno del server',
});
});
return { app, container };
}
module.exports = createApp;
Dependency Injection con scope per richiesta
In alcune situazioni è necessario che una dipendenza abbia un ciclo di vita legato alla singola richiesta HTTP. Un caso tipico è la gestione del contesto utente autenticato: dopo aver verificato il token JWT, vogliamo rendere disponibile l'utente corrente a tutti i componenti coinvolti nella gestione della richiesta senza passarlo esplicitamente come parametro.
// src/middleware/scopeMiddleware.js
function createScopeMiddleware(container) {
return (req, res, next) => {
// Crea uno scope isolato per ogni richiesta HTTP
req.container = container.createScope();
// Pulizia dello scope al termine della richiesta
res.on('finish', () => {
req.container.dispose();
});
next();
};
}
module.exports = createScopeMiddleware;
// src/middleware/authMiddleware.js
const jwt = require('jsonwebtoken');
const { asValue } = require('awilix');
function createAuthMiddleware(jwtSecret) {
return (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ success: false, message: 'Token non fornito' });
}
const token = authHeader.split(' ')[1];
try {
const payload = jwt.verify(token, jwtSecret);
// Registra l'utente corrente nello scope della richiesta
req.container.register({
currentUser: asValue(payload),
});
next();
} catch {
res.status(401).json({ success: false, message: 'Token non valido' });
}
};
}
module.exports = createAuthMiddleware;
Un servizio che dipende dal contesto utente può ora riceverlo tramite injection:
// src/services/postService.js
class PostService {
constructor(postRepository, currentUser) {
// currentUser viene iniettato dallo scope della richiesta
this.postRepository = postRepository;
this.currentUser = currentUser;
}
async createPost(postData) {
// Associa automaticamente il post all'utente autenticato
return this.postRepository.save({
...postData,
authorId: this.currentUser.id,
createdAt: new Date(),
});
}
async getMyPosts() {
return this.postRepository.findByAuthorId(this.currentUser.id);
}
}
module.exports = PostService;
Pattern avanzati: interfacce e inversione delle dipendenze
La DI è strettamente legata al principio SOLID di Inversione delle Dipendenze (Dependency Inversion Principle): i moduli di alto livello non devono dipendere da moduli di basso livello, ma entrambi devono dipendere da astrazioni.
JavaScript non supporta nativamente le interfacce come TypeScript o Java, ma è possibile simularne il concetto utilizzando classi base o documentazione delle forme contrattuali attese. In TypeScript, questo diventa ancora più esplicito:
// src/interfaces/IUserRepository.ts
// Definisce il contratto che qualsiasi repository utenti deve rispettare
interface IUserRepository {
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
save(userData: CreateUserDto): Promise<User>;
update(id: string, userData: Partial<User>): Promise<User>;
delete(id: string): Promise<void>;
}
// src/repositories/mongoUserRepository.ts
// Implementazione concreta per MongoDB
class MongoUserRepository implements IUserRepository {
constructor(private readonly db: Db) {}
async findById(id: string): Promise<User | null> {
return this.db.collection('users').findOne({ _id: new ObjectId(id) });
}
// ... altre implementazioni
}
// src/repositories/inMemoryUserRepository.ts
// Implementazione in memoria per i test
class InMemoryUserRepository implements IUserRepository {
private readonly users: Map<string, User> = new Map();
async findById(id: string): Promise<User | null> {
return this.users.get(id) ?? null;
}
async save(userData: CreateUserDto): Promise<User> {
const user = { ...userData, _id: Math.random().toString() };
this.users.set(user._id, user);
return user;
}
// ... altre implementazioni
}
Questa separazione tra interfaccia e implementazione consente di utilizzare l'implementazione reale in produzione e quella in memoria durante i test, senza alcuna modifica ai servizi che dipendono dal repository.
Gestione delle dipendenze circolari
Un problema che può presentarsi nelle applicazioni di medie e grandi dimensioni è la dipendenza circolare: il modulo A dipende da B, che a sua volta dipende da A. Questo crea un ciclo che può causare errori difficili da diagnosticare.
La soluzione preferita è ristrutturare il codice per eliminare il ciclo, spesso introducendo un terzo componente che entrambi possono usare senza dipendere l'uno dall'altro. Se la ristrutturazione non è praticabile, è possibile utilizzare il pattern lazy injection tramite una factory:
// Evita le dipendenze circolari con una factory function
class ServiceA {
constructor(serviceBFactory) {
// La dipendenza viene risolta in modo lazy al momento dell'utilizzo
this.serviceBFactory = serviceBFactory;
}
doSomething() {
// Risolve ServiceB solo quando necessario, non nel costruttore
const serviceB = this.serviceBFactory();
return serviceB.helperMethod();
}
}
// Nel container, registra ServiceB come factory
container.register({
serviceB: asClass(ServiceB, { lifetime: Lifetime.SINGLETON }),
serviceBFactory: asFunction(({ serviceB }) => () => serviceB),
serviceA: asClass(ServiceA, { lifetime: Lifetime.SINGLETON }),
});
Considerazioni sulle performance
L'uso di un container IoC introduce un piccolo overhead nella fase di avvio dell'applicazione, poiché il container deve risolvere il grafo completo delle dipendenze. Tuttavia, questo costo è generalmente trascurabile per applicazioni server-side, dove l'avvio avviene una sola volta.
Per i componenti con ciclo di vita SINGLETON, le dipendenze vengono create e memorizzate nella cache una sola volta. Le richieste successive allo stesso componente restituiscono l'istanza già creata senza alcun overhead aggiuntivo.
Per i componenti con ciclo di vita SCOPED o TRANSIENT, il container deve creare nuove istanze più frequentemente. In scenari con alto throughput, è importante misurare l'impatto e preferire i singleton ove possibile, riservando gli scope alle dipendenze che effettivamente richiedono isolamento per richiesta.
Conclusioni
La Dependency Injection è un pattern che, una volta adottato, trasforma radicalmente la qualità e la manutenibilità del codice. In Node.js con ExpressJS è possibile implementarla a diversi livelli di sofisticazione: dalla semplice iniezione manuale nel costruttore, fino all'utilizzo di container IoC come Awilix con supporto per scope per richiesta.
I punti chiave da ricordare sono: creare le dipendenze sempre e solo nel composition root, far dipendere i moduli da astrazioni e non da implementazioni concrete, sfruttare la DI per rendere i test unitari semplici e veloci, e scegliere il ciclo di vita corretto (singleton, scoped, transient) per ogni dipendenza.
Applicare questi principi in modo consistente porta a un'architettura più flessibile, dove aggiungere funzionalità, cambiare implementazioni o scrivere test diventa un'operazione naturale e priva di effetti collaterali indesiderati.