Patrones de diseño en Node.js
Patrones de diseño empresariales para aplicaciones Node.js escalables, incluyendo arquitectura limpia, CQRS, y patrones de microservicios.
Introducción
Los patrones de diseño son soluciones probadas a problemas comunes en el desarrollo de software. En Node.js, ciertos patrones son especialmente útiles debido a su naturaleza asíncrona y orientada a eventos. Empresas como LinkedIn, Netflix, PayPal y Uber utilizan estos patrones para construir aplicaciones escalables que manejan millones de usuarios.
1. Patrón Singleton
Garantiza que una clase tenga solo una instancia y proporciona un punto de acceso global a ella. Ideal para conexiones a base de datos, configuraciones y caches.
class DatabaseConnection {
constructor() {
if (DatabaseConnection.instance) {
return DatabaseConnection.instance;
}
this.connection = null;
DatabaseConnection.instance = this;
}
async connect() {
if (!this.connection) {
this.connection = await mongoose.connect(process.env.MONGODB_URI, {
useNewUrlParser: true,
useUnifiedTopology: true,
poolSize: 10
});
console.log('Database connected');
}
return this.connection;
}
getConnection() {
if (!this.connection) {
throw new Error('Database not connected');
}
return this.connection;
}
}
// Siempre devuelve la misma instancia
const db1 = new DatabaseConnection();
const db2 = new DatabaseConnection();
console.log(db1 === db2); // true
// Exportar instancia única
module.exports = new DatabaseConnection();
2. Patrón Factory
Proporciona una interfaz para crear objetos sin especificar sus clases exactas. Usado por frameworks como NestJS.
// Factory para diferentes tipos de notificaciones
class NotificationFactory {
createNotification(type, config) {
switch(type) {
case 'email':
return new EmailNotification(config);
case 'sms':
return new SMSNotification(config);
case 'push':
return new PushNotification(config);
case 'slack':
return new SlackNotification(config);
default:
throw new Error(`Tipo de notificación no válido: ${type}`);
}
}
}
class EmailNotification {
constructor(config) {
this.transporter = nodemailer.createTransport(config);
}
async send(to, subject, body) {
await this.transporter.sendMail({ to, subject, html: body });
}
}
class SMSNotification {
constructor(config) {
this.client = twilio(config.accountSid, config.authToken);
}
async send(to, message) {
await this.client.messages.create({
body: message,
to,
from: this.config.from
});
}
}
// Uso
const factory = new NotificationFactory();
const emailNotifier = factory.createNotification('email', emailConfig);
await emailNotifier.send('user@example.com', 'Welcome', 'Hello!');
3. Patrón Observer (EventEmitter)
Node.js tiene soporte nativo para este patrón a través de EventEmitter. Fundamental para arquitecturas orientadas a eventos.
const EventEmitter = require('events');
class OrderProcessor extends EventEmitter {
async processOrder(order) {
console.log('Procesando pedido...');
try {
// Procesar pago
await this.processPayment(order);
this.emit('paymentProcessed', order);
// Actualizar inventario
await this.updateInventory(order);
this.emit('inventoryUpdated', order);
// Pedido completado
this.emit('orderCompleted', order);
} catch (error) {
this.emit('orderFailed', order, error);
}
}
async processPayment(order) {
// Lógica de pago
}
async updateInventory(order) {
// Lógica de inventario
}
}
const processor = new OrderProcessor();
// Suscribirse a eventos
processor.on('paymentProcessed', (order) => {
console.log(`Pago procesado para pedido ${order.id}`);
// Enviar confirmación por email
});
processor.on('inventoryUpdated', (order) => {
console.log(`Inventario actualizado para pedido ${order.id}`);
});
processor.on('orderCompleted', (order) => {
console.log(`Pedido ${order.id} completado`);
// Notificar al usuario
});
processor.on('orderFailed', (order, error) => {
console.error(`Error en pedido ${order.id}:`, error);
// Rollback y notificación
});
await processor.processOrder({ id: 123, items: [] });
4. Patrón Middleware (Chain of Responsibility)
Fundamental en Express.js y otras frameworks de Node.js. Permite procesar requests a través de una cadena de handlers.
// Middleware de autenticación
const authMiddleware = async (req, res, next) => {
try {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No autorizado' });
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = await User.findById(decoded.id);
next();
} catch (error) {
res.status(401).json({ error: 'Token inválido' });
}
};
// Middleware de logging
const logMiddleware = (req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
console.log(`${req.method} ${req.url} - ${res.statusCode} - ${duration}ms`);
});
next();
};
// Middleware de rate limiting
const rateLimitMiddleware = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
message: 'Demasiadas peticiones'
});
// Middleware de validación
const validateMiddleware = (schema) => {
return (req, res, next) => {
const { error } = schema.validate(req.body);
if (error) {
return res.status(400).json({ error: error.details[0].message });
}
next();
};
};
// Aplicar middlewares
app.use(logMiddleware);
app.use(rateLimitMiddleware);
app.post('/api/users', validateMiddleware(userSchema), authMiddleware, createUser);
5. Patrón Repository
Abstrae la capa de acceso a datos, facilitando testing y cambios de base de datos. Usado por empresas para mantener código limpio y testeable.
// Interface del repositorio
class UserRepository {
constructor(database) {
this.db = database;
}
async findById(id) {
return await this.db.users.findOne({ _id: id });
}
async findByEmail(email) {
return await this.db.users.findOne({ email });
}
async findAll(filters = {}, options = {}) {
const { page = 1, limit = 10, sort = { createdAt: -1 } } = options;
const skip = (page - 1) * limit;
return await this.db.users
.find(filters)
.sort(sort)
.skip(skip)
.limit(limit);
}
async create(userData) {
const user = new this.db.users(userData);
return await user.save();
}
async update(id, userData) {
return await this.db.users.findByIdAndUpdate(
id,
{ $set: userData },
{ new: true, runValidators: true }
);
}
async delete(id) {
return await this.db.users.findByIdAndDelete(id);
}
async count(filters = {}) {
return await this.db.users.countDocuments(filters);
}
}
// Uso en servicio
class UserService {
constructor(userRepository) {
this.userRepository = userRepository;
}
async getUser(id) {
const user = await this.userRepository.findById(id);
if (!user) {
throw new Error('Usuario no encontrado');
}
return user;
}
async createUser(userData) {
const existingUser = await this.userRepository.findByEmail(userData.email);
if (existingUser) {
throw new Error('Email ya registrado');
}
return await this.userRepository.create(userData);
}
}
// Dependency Injection
const userRepo = new UserRepository(database);
const userService = new UserService(userRepo);
6. Patrón Dependency Injection
Facilita testing y desacoplamiento. Usado por frameworks como NestJS y InversifyJS.
// Container de dependencias
class Container {
constructor() {
this.services = new Map();
}
register(name, definition, dependencies = []) {
this.services.set(name, { definition, dependencies });
}
get(name) {
const service = this.services.get(name);
if (!service) {
throw new Error(`Servicio ${name} no encontrado`);
}
const { definition, dependencies } = service;
const resolvedDependencies = dependencies.map(dep => this.get(dep));
return new definition(...resolvedDependencies);
}
}
// Registro de servicios
const container = new Container();
container.register('database', DatabaseConnection);
container.register('userRepository', UserRepository, ['database']);
container.register('userService', UserService, ['userRepository']);
container.register('authService', AuthService, ['userService']);
// Uso
const authService = container.get('authService');
7. Patrón CQRS (Command Query Responsibility Segregation)
Separa operaciones de lectura y escritura. Usado por aplicaciones de alta escala como LinkedIn.
// Commands (escritura)
class CreateUserCommand {
constructor(userData) {
this.userData = userData;
}
}
class UpdateUserCommand {
constructor(userId, updates) {
this.userId = userId;
this.updates = updates;
}
}
// Command Handlers
class CreateUserHandler {
constructor(userRepository, eventBus) {
this.userRepository = userRepository;
this.eventBus = eventBus;
}
async handle(command) {
const user = await this.userRepository.create(command.userData);
// Emitir evento
this.eventBus.emit('UserCreated', {
userId: user.id,
email: user.email,
timestamp: new Date()
});
return user;
}
}
// Queries (lectura)
class GetUserQuery {
constructor(userId) {
this.userId = userId;
}
}
class GetUsersQuery {
constructor(filters, pagination) {
this.filters = filters;
this.pagination = pagination;
}
}
// Query Handlers
class GetUserHandler {
constructor(userReadModel) {
this.userReadModel = userReadModel;
}
async handle(query) {
return await this.userReadModel.findById(query.userId);
}
}
// Command Bus
class CommandBus {
constructor() {
this.handlers = new Map();
}
register(commandType, handler) {
this.handlers.set(commandType, handler);
}
async execute(command) {
const handler = this.handlers.get(command.constructor);
if (!handler) {
throw new Error(`No handler for ${command.constructor.name}`);
}
return await handler.handle(command);
}
}
// Uso
const commandBus = new CommandBus();
commandBus.register(CreateUserCommand, new CreateUserHandler(userRepo, eventBus));
const command = new CreateUserCommand({ email: 'user@example.com', name: 'John' });
const user = await commandBus.execute(command);
8. Patrón Circuit Breaker
Previene fallos en cascada en arquitecturas de microservicios. Usado por Netflix en su stack.
class CircuitBreaker {
constructor(request, options = {}) {
this.request = request;
this.state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN
this.failureCount = 0;
this.successCount = 0;
this.nextAttempt = Date.now();
this.threshold = options.threshold || 5;
this.timeout = options.timeout || 60000;
this.resetTimeout = options.resetTimeout || 30000;
}
async call(...args) {
if (this.state === 'OPEN') {
if (Date.now() < this.nextAttempt) {
throw new Error('Circuit breaker is OPEN');
}
this.state = 'HALF_OPEN';
}
try {
const response = await this.request(...args);
this.onSuccess();
return response;
} catch (error) {
this.onFailure();
throw error;
}
}
onSuccess() {
this.failureCount = 0;
if (this.state === 'HALF_OPEN') {
this.state = 'CLOSED';
this.successCount = 0;
}
}
onFailure() {
this.failureCount++;
if (this.failureCount >= this.threshold) {
this.state = 'OPEN';
this.nextAttempt = Date.now() + this.resetTimeout;
console.log('Circuit breaker opened');
}
}
}
// Uso con servicio externo
const paymentServiceBreaker = new CircuitBreaker(
async (orderId, amount) => {
return await axios.post('https://payment-service.com/charge', {
orderId,
amount
});
},
{ threshold: 5, resetTimeout: 30000 }
);
try {
const result = await paymentServiceBreaker.call(orderId, amount);
} catch (error) {
// Fallback: guardar en cola para retry
await queuePayment(orderId, amount);
}
9. Patrón Strategy
Permite cambiar algoritmos dinámicamente. Útil para diferentes estrategias de pricing, shipping, etc.
// Estrategias de pricing
class RegularPricing {
calculate(basePrice, quantity) {
return basePrice * quantity;
}
}
class BulkPricing {
calculate(basePrice, quantity) {
if (quantity >= 100) return basePrice * quantity * 0.8;
if (quantity >= 50) return basePrice * quantity * 0.9;
return basePrice * quantity;
}
}
class VIPPricing {
calculate(basePrice, quantity) {
return basePrice * quantity * 0.85; // 15% descuento
}
}
// Context
class PriceCalculator {
constructor(strategy) {
this.strategy = strategy;
}
setStrategy(strategy) {
this.strategy = strategy;
}
calculate(basePrice, quantity) {
return this.strategy.calculate(basePrice, quantity);
}
}
// Uso
const calculator = new PriceCalculator(new RegularPricing());
let price = calculator.calculate(10, 5); // 50
// Cambiar estrategia para cliente VIP
calculator.setStrategy(new VIPPricing());
price = calculator.calculate(10, 5); // 42.5
Conclusión
Los patrones de diseño son fundamentales para construir aplicaciones Node.js escalables y mantenibles. Empresas como LinkedIn, Netflix, PayPal y Uber utilizan estos patrones para:
- Singleton: Gestión eficiente de recursos compartidos
- Factory: Creación flexible de objetos
- Observer: Arquitecturas orientadas a eventos
- Repository: Abstracción de datos y facilidad de testing
- Dependency Injection: Desacoplamiento y testabilidad
- CQRS: Escalabilidad en operaciones de lectura/escritura
- Circuit Breaker: Resiliencia en microservicios
- Strategy: Flexibilidad en lógica de negocio
Dominar estos patrones te permitirá diseñar sistemas robustos que escalan a millones de usuarios.
Gustavo Leyva
Desarrollador de Software