Buenas, permíteme darte la bienvenida a mi primer artículo. Este nace con la finalidad de llevar a texto mis ideas respecto de los problemas de MVC en la práctica, y cómo migrar de a poco, desde una arquitectura MVC a una arquitectura aplica parte de los principios del diseño basado en el dominio (DDD), y que además, tiene un enfoque fuerte en ser fácil de testear.
¿Qué es MVC? de la academia a sus problemas en la práctica
MVC es un patrón arquitectónico que plantea la factorización de una aplicación en 3 clases principales: Modelo, Vista y Controlador, los cuales en palabras de Glenn E. Krasner y Stephen T. Pope son:
El modelo se encarga de las operaciones relacionadas al dominio de la aplicación, la vista se encarga de mostrar el estado de la aplicación, mientras que el controlador se encarga de manejar las interacciones del usuario con el modelo y la vista
A Cookbook for Using View-Controller User the Model-Interface Paradigm in Smalltalk-80
Hoy en día, al menos recordando cómo me lo enseñaron en la universidad, y la forma en la que lo he visto aplicado a través de diferentes frameworks y proyectos web, yo lo definiría de la siguiente forma:
El modelo se encarga de gestionar la representación de las entidades del dominio y la persistencia de las mismas, mientras que la vista se encarga de responder a las interacciones directas del usuario con la interfaz, y el controlador permite gestionar la comunicación entre la vista y el estado de la aplicación en persistencia.
Mi definición seguramente es bastante limitada pues aterriza demasiado en especificaciones del modelo web, y no se abstrae de lo que busca ser un patrón arquitectónico como tal.
Pero resulta que cuando veo un proyecto aplicando MVC, siempre es lo mismo, por ejemplo, un proyecto utilizando Laravel donde los modelos no requieren definir métodos extras porque ya trae todo hecho pensando en ir a la base de datos y manejar la persistencia, controladores enormes con cientos de métodos y miles de líneas de código ensamblando lógica de aplicación, de negocio y algunos adaptadores hacia interfaces de sistemas externos, y las vistas, llenas de lógica de aplicación y negocio que quizá debió haber sido procesada previamente por el controlador, pero que de nuevo, le sigue sumando más carga al controlador...
Y al final, ¿cómo testeamos un controlador?, tenemos que partir de la base de que estamos manipulando lógica de las llamadas HTTP/S que éste debe recibir, y además de ello, debe manipular la persistencia de la aplicación (ya que tampoco he visto un controlador que no utilice directamente los modelos en vez de usar alguna inyección o inversión de dependencias), en fin, se vuelve caótico, y más caótico aún se vuelve un software que no tiene test.
De todos modos, no me cierro a decir que Laravel es un framework MVC, su mismo autor afirma lo contrario:
Pero a lo que voy es que es muy común ver aplicaciones como las que muestro a continuación.
Ejemplo práctico de un Controlador aplicando MVC:
A continuación, un código de un Controlador creado por ChatGPT y modificado por mí, el cual realiza lo siguiente:
- Obtener todos los productos: Recupera una lista de todos los productos desde la base de datos.
- Obtener un producto específico por ID: Recupera los detalles de un producto específico según su ID. Además, realiza una llamada ficticia a una API para obtener una lista de productos similares.
- Crear un nuevo producto: Agrega un nuevo producto a la base de datos. También realiza una consulta a una API ficticia para obtener etiquetas basadas en el nombre del producto.
- Actualizar un producto existente: Modifica los detalles de un producto existente según su ID.
- Eliminar un producto: Elimina un producto de la base de datos según su ID.
- Envío de correo electrónico: Envía un correo electrónico ficticio a la empresa cuando se agrega un nuevo producto.
- Obtener productos similares: Realiza una llamada ficticia a una API para obtener productos similares antes de mostrar un producto específico.
const express = require('express');
const router = express.Router();
const Product = require('../models/product');
const axios = require('axios');
const logger = require('../logger');
const mailer = require('../mailer');
router.get('/products/:id', async (req, res) => {
try {
const product = await Product.findById(req.params.id);
if (product == null) {
logger.error('Product not found');
return res.status(404).json({ message: 'Product not found' });
}
logger.info(`Get product by ID: ${product._id}`);
// Call a fictitious API to get similar products
try {
const response = await axios.get(`https://api.example.com/similars?product=${product.name}`);
product.similars = response.data.products;
} catch (error) {
logger.error(`Error getting similar products: ${error.message}`);
product.similars = [];
}
res.json(product);
} catch (error) {
logger.error(`Error while getting a product by ID: ${error.message}`);
res.status(500).json({ message: error.message });
}
});
router.post('/products', async (req, res) => {
try {
const product = new Product({
name: req.body.name,
description: req.body.description,
price: req.body.price,
});
const newProduct = await product.save();
logger.info(`Create new product: ${newProduct._id}`);
try {
const response = await axios.get(`https://api.example.com/tags?product=${req.body.name}`);
newProduct.tags = response.data.tags;
await newProduct.save();
} catch (error) {
logger.error(`Error while querying tag API: ${error.message}`);
}
// Send an email to the company when adding a new product
try {
await mailer.sendMail({
to: 'company@example.com',
subject: 'New Product Added',
text: `A new product has been added: ${newProduct.name}`,
});
} catch (error) {
logger.error(`Error while sending email: ${error.message}`);
}
res.status(201).json(newProduct);
} catch (error) {
logger.error(`Error while creating a new product: ${error.message}`);
res.status(400).json({ message: error.message });
}
});
router.patch('/products/:id', async (req, res) => {
try {
const product = await Product.findById(req.params.id);
if (product == null) {
logger.error('Product not found');
return res.status(404).json({ message: 'Product not found' });
}
if (req.body.name != null) {
product.name = req.body.name;
}
if (req.body.description != null) {
product.description = req.body.description;
}
if (req.body.price != null) {
product.price = req.body.price;
}
const updatedProduct = await product.save();
logger.info(`Update product by ID: ${updatedProduct._id}`);
res.json(updatedProduct);
} catch (error) {
logger.error(`Error while updating a product: ${error.message}`);
res.status(400).json({ message: error.message });
}
});
router.delete('/products/:id', async (req, res) => {
try {
const product = await Product.findById(req.params.id);
if (product == null) {
logger.error('Product not found');
return res.status(404).json({ message: 'Product not found' });
}
await product.remove();
logger.info(`Delete product by ID: ${req.params.id}`);
res.json({ message: 'Product deleted successfully' });
} catch (error) {
logger.error(`Error while deleting a product: ${error.message}`);
res.status(500).json({ message: error.message });
}
});
module.exports = router;
Este único controlador tiene un total de 127 líneas, y pese a ser una versión ficticia de un negocio con pocas reglas, será difícil de mantener en caso de que el negocio escale, o en caso de necesitar añadir más métodos.
Aspectos notables de esta implementación:
- Estamos concentrando todo lo relacionado a un producto como un único controlador, y esto está generando que se acople todo
- El controlador depende directamente de los modelos, en vez de tener una composición, lo que los hace altamente acoplados y no permite testear individualmente sus métodos
- Los métodos del controlador están escritos como funciones anónimas, lo que nos obliga a levantar nuestro servidor en express para testearlas mediante herramientas como supertest. ¿Es esto unitario?, en mi opinión, no.
- El controlador se encarga no sólo de manejar las solicitudes HTTP, si no que también, de manejar reglas de negocio como qué hacer en caso de que un producto no se encuentre, del mismo modo, el controlador maneja servicios externos, como una API de productos similares o de tags, generando una alta acoplación.
Por lo mismo, es mi objetivo en este post, plantear cómo pasar de este tipo de arquitecturas, a algo más fácil de testear, y que haga uso de algunos aspectos del Diseño Basado en el Dominio (DDD). Esta propuesta no pretende ser una arquitectura perfecta, pero sí punto de partida para comenzar a mejorar, y luego dar paso a cambios más drásticos que apunten a una arquitectura limpia aplicando SOLID y DDD como lo hizo en su momento Khalil Stemmler en su libro https://solidbook.io/
Expectativa ideal: MVC con DDD y Testing
According to Eric Evans, Domain-driven design (DDD) is not a technology or a methodology. It’s a different way of thinking about how to organize your applications and structure your code. This way of thinking complements very well the popular MVC architecture
Veamos un diagrama de lo que actualmente tenemos:
O siendo un poco más honestos:
¿Qué pasa si comenzamos a particionar nuestro controlador?. podemos extraer de él cosas como los servicios externos, los casos de uso, y lograr asociar nuestro lenguaje ubicuo a algo más cercano a lo que propone DDD, más allá de las 3 entidades con las que partimos.
Obtendríamos algo como esto:
En la práctica, ésto nos permite lo siguiente:
- Separados los casos de uso y servicios del controlador, ahora podemos generar casos de uso de forma independiente, y éstos en vez de estar altamente acoplados a los servicios y modelos, podemos entregárselos al caso de uso mediante una inyección de dependencias, de modo tal que el caso de uso no se preocupe realmente de qué está utilizando para lograr su cometido, sólo lo utiliza y ya
- Ahora los servicios pueden tener su propia lógica, y no depender directamente de un cliente HTTP, ya que también, pueden recibirlo por inyección de dependencias
- A la hora de testear, ya no tenemos que preocuparnos de levantar un servidor express completo, pues basta con testear unitariamente el caso de uso y el servicio con sus respectivos mocks por inyección de dependencias y listo, sin stubs, spyes ni otras herramientas de jest que modifiquen las implementaciones del código (lo que no está mal, sin embargo, creo que un test unitario debería ser siempre fácil de llevar a cabo, y no ser completamente dependiente de las utilidades que el framework entrega para desarrollarlo).
- Dejar más en claro la segregación de responsabilidades, quitándole carga al controlador
Para lograr esto, yo propongo el siguiente plan de migración.
Migración P1: desacoplando Servicios de Controladores
Tomemos por ejemplo el endpoint de crear un producto, cuyo código es:
router.post('/products', async (req, res) => {
try {
const product = new Product({
name: req.body.name,
description: req.body.description,
price: req.body.price,
});
const newProduct = await product.save();
logger.info(`Create new product: ${newProduct._id}`);
try {
const response = await axios.get(`https://api.example.com/tags?product=${req.body.name}`);
newProduct.tags = response.data.tags;
await newProduct.save();
} catch (error) {
logger.error(`Error while querying tag API: ${error.message}`);
}
// Send an email to the company when adding a new product
try {
await mailer.sendMail({
to: 'company@example.com',
subject: 'New Product Added',
text: `A new product has been added: ${newProduct.name}`,
});
} catch (error) {
logger.error(`Error while sending email: ${error.message}`);
}
res.status(201).json(newProduct);
} catch (error) {
logger.error(`Error while creating a new product: ${error.message}`);
res.status(400).json({ message: error.message });
}
});
Notamos que hay 2 llamadas a servicios externos: por un lado, una llamada a una API de tags, para crear una lista de tags de cada producto, por el otro, el envío de correos cada vez que se crea un producto nuevo.
Esto nos da pistas de las primeras cosas que podemos extraer: los servicios. Comenzamos creando 2 servicios distintos, EmailService y ProductService.
// EmailService.js
export const EmailService = (Mailer) => {
const mailer = Mailer;
const sendEmail = async ({ to, subject, text }) => {
try {
await mailer.sendMail({ to, subject, text });
} catch (error) {
logger.error(`Error while sending email: ${error.message}`);
throw error;
}
};
return { sendEmail };
};
// ProductService.js
export const ProductService = (Client) => {
const client = Client;
const getTagsByName = async (productName) => {
try {
const { data } = await client.get(
`https://api.example.com/tags?product=${productName}`
);
return data.tags;
} catch (error) {
logger.error(`Error while querying tag API: ${error.message}`);
throw error;
}
};
return { getTagsByName };
};
Notar que los servicios fueron implementados de forma funcional, es decir, en vez de hacer una clase, se crea una función que al ejecutarse se le entrega como parámetro una instancia de la dependencia a utilizar, para luego retornar un objeto cuyos atributos son los métodos del servicio, los cuales utilizan la instancia de la dependencia entregada mediante una inyección, evitando así acoplar axios y mailer, para facilitar su posterior testing.
Ahora nos falta generar instancias importables de estos servicios, para ello haremos una suerte del patrón Singleton, al crear un archivo index.js en una carpeta Services, el cual exporte instancias de todos nuestros servicios.
// index.js
import { EmailService } from '../EmailService';
import { ProductService } from '../ProductService';
import axios from 'axios';
import mailer from 'nodemailer';
const transporter = nodemailer.createTransport({
host: "smtp.forwardemail.net",
port: 465,
secure: true,
auth: {
user: "REPLACE-WITH-YOUR-ALIAS@YOURDOMAIN.COM",
pass: "REPLACE-WITH-YOUR-GENERATED-PASSWORD",
},
});
const productServiceHttpClient = axios.create({baseUrl: 'https://api.example.com/'});
const emailService = EmailService(transporter);
const productService = ProductService(productServiceHttpClient);
export { emailService, productService };
Ahora podemos utilizar nuestros servicios simplemente importando sus instancias desde el archivo index.js en alguna carpeta, por ejemplo, services.
import { emailService, productService } from '../services';
Hecho esto, el endpoint de nuestro controlador procedería a verse así
import { emailService, productService } from '../services';
router.post('/products', async (req, res) => {
try {
const tags = await productService.getTagsByName(req.body.name);
const product = new Product({
name: req.body.name,
description: req.body.description,
price: req.body.price,
tags
});
const newProduct = await product.save();
logger.info(`Create new product: ${newProduct._id}`);
// Send an email to the company when adding a new product
const emailOptions = {
to: 'company@example.com',
subject: 'New Product Added',
text: `A new product has been added: ${newProduct.name}`,
}
await emailService.sendEmail(emailOptions);
res.status(201).json(newProduct);
} catch (error) {
logger.error(`Error while creating a new product: ${error.message}`);
res.status(400).json({ message: error.message });
}
});
Hemos encapsulado la lógica de obtener los tags de un producto y enviar mails en 2 servicios distintos, los cuales reciben su respectiva infraestructura para trabajar mediante una inyección de dependencias, lo que nos permite fácilmente testearlos. Llegados a este punto, ya podemos testear una porción de lo que anteriormente era la lógica de nuestro controlador, teniendo unitariamente listos todo lo relacionado a enviar mails y pedir tags de productos.
Aún nos falta:
- Desacoplar el modelo del controlador
- Desacoplar la lógica http de la lógica de caso de uso
- Desacoplar el caso de uso del controlador (estos 2 últimos son prácticamente lo mismo)
Migración P2: desacoplando Repositorios de Controladores
En vez de representar los objetos de nuestro dominio utilizando meramente modelos, vamos a añadir un nivel de abstracción intermedio con la implementación de repositorios.
Los repositorios se utilizan para almacenar, obtener y borrar objetos del dominio desde distintas implementaciones de almacenamiento. Por ejemplo, puedes utilizar un Repositorio para almacenar el Perfil que tu Fábrica creó.
Evans, E. (2004). Domain-Driven Design: Tackling Complexity in the Heart of Software. Addison-Wesley.
Para esto, vamos a crear un repositorio asociado a los productos: ProductRepository, el cual tendrá métodos para manejar la persistencia de los productos de nuestro sistema.
// ProductRepository.js
export const ProductRepository = (ProductModel) => {
const model = ProductModel;
const create = async (rawProduct) => {
const product = {
name: rawProduct.name,
description: rawProduct.description,
price: rawProduct.price,
tags: rawProduct.tags
};
return model.create(product);
}
return { create };
}
Creamos su respectivo archivo index.js en alguna carpeta repositories, el cual aplicando nuevamente el patrón Singleton, va a instanciar nuestros repositorios.
// index.js
import { ProductRepository } from '../ProductRepository';
import { ProductModel } from '../models';
const productRepository = ProductRepository(ProductModel);
export { productRepository };
Es importante destacar que un repositorio no está limitado a un único modelo, por lo que se pueden pasar mediante inyección de dependencias modelos de otras entidades de la aplicación sin problemas.
Ahora nuestro endpoint se vería así:
import { emailService, productService } from '../services';
import { productRepository } from '../repositories';
router.post('/products', async (req, res) => {
try {
const tags = await productService.getTagsByName(req.body.name);
const product = {
name: req.body.name,
description: req.body.description,
price: req.body.price,
tags
};
const newProduct = await productRepository.create(product);
logger.info(`Create new product: ${newProduct._id}`);
// Send an email to the company when adding a new product
const emailOptions = {
to: 'company@example.com',
subject: 'New Product Added',
text: `A new product has been added: ${newProduct.name}`,
}
await emailService.sendEmail(emailOptions);
res.status(201).json(newProduct);
} catch (error) {
logger.error(`Error while creating a new product: ${error.message}`);
res.status(400).json({ message: error.message });
}
});
Ahora podemos testear unitariamente los métodos de nuestro repositorio, ganando otra porción más del código para el coverage de nuestro artefacto.
Migración P3: desacoplando Casos de Uso de Controladores
Finalmente, nos queda extraer la lógica de negocio de nuestro controlador, para esto, utilizaremos Casos de Uso.
The first bullet—use cases—means that the architecture of the system must support the intent of the system. If the system is a shopping cart application, then the architecture must support shopping cart use cases. Indeed, this is the first concern of the architect, and the first priority of the architecture. The architecture must support the use cases.
Martin, R. C. (2017). Clean Architecture: A Craftsman's Guide to Software Structure and Design. Boston, MA: Prentice Hall. ISBN: 978-0-13-449416-6
Los Casos de Uso nos permitirán demostrar la intención que tiene nuestro sistema, así como también, concentrar toda la lógica particular de un aspecto del negocio en un sólo lugar. Para ello, vamos a crear un Caso de Uso llamado CreateProduct.
// CreateProductUseCase.js
export const CreateProductUseCase = (ProductRepository, EmailService, ProductService) => {
const productRepository = ProductRepository;
const productService = ProductService;
const emailService = EmailService;
const execute = async (dto) => {
const tags = await productService.getTagsByName(req.body.name);
const product = {
name: dto.name,
description: dto.description,
price: dto.price,
tags
};
const newProduct = await productRepository.create(product);
logger.info(`Create new product: ${newProduct._id}`);
// Send an email to the company when adding a new product
const emailOptions = {
to: 'company@example.com',
subject: 'New Product Added',
text: `A new product has been added: ${newProduct.name}`,
}
await emailService.sendEmail(emailOptions);
return newProduct;
}
return { execute }
}
Ciertamente, todo lo que hice fue mover lo que estaba en el controlador que no tocaba los objetos res o req. Pero como resultado, ahora puedo testear todo ese código sin levantar un servidor express, sólo necesito generar una instancia con los mocks adecuados para ejecutar y testear unitariamente todo el contenido del Caso de Uso.
Nuevamente, aplicamos Singleton para utilizar el Caso de Uso en otras partes de la aplicación.
// index.js
import { CreateProductUseCase } from '../CreateProduct';
import { productRepository } from '../repositories';
import { emailService, productService } from '../services';
const createProductUseCase = CreateProductUseCase(productRepository, emailService, productService);
export { createProductUseCase }
Ahora nuestro controlador se vería así:
import { createProductUseCase } from '../usecases';
router.post('/products', async (req, res) => {
try {
const dto = {
name: req.body.name,
description: req.body.description,
price: req.body.price,
}
const newProduct = await createProductUseCase.execute(dto)
res.status(201).json(newProduct);
} catch (error) {
logger.error(`Error while creating a new product: ${error.message}`);
res.status(400).json({ message: error.message });
}
});
Entregándole un Data Transfer Object (básicamente, el objeto que necesita el Caso de Uso para trabajar), al método de la ejecución del Caso de Uso. Ahora nuestro controlador sólo se encarga de manejar las solicitudes / respuestas HTTP con los objetos del framework (en este caso asumamos express), y de llamar al Caso de Uso, el cual podemos testear fácilmente.
Notar que he dejado la clase logger acoplada a todas estas entidades que he creado, esto fue con el fin de dejar en claro que no es "ilegal" generar alto acoplamiento, el objetivo de toda esta reestructuración es poder hacer algo más testeable y entendible según los términos del DDD, un logger no nos impide testear unitariamente nuestras entidades, por lo que está bien importarlo directamente y utilizarlo sin pasarlo por inyección de dependencias.
Migración P4 (plus): Controladores por Caso de Uso
Con la P3 de la migración, ya tenemos una arquitectura suficientemente testeable para efectos del core de nuestro producto, sin embargo, ¿por qué detenernos allí?
Recordemos que nuestro controlador tenía 127 líneas, si bien éstas se acortan mucho llamando solo a los Casos de Uso, no es escalable colocar todos los métodos de nuestro controlador en el mismo archivo, puede llegar a ser confuso que un archivo como ProductController tenga, por algún motivo, métodos para vender productos combinados con métodos para crearlos. Por lo que a continuación, propongo otra forma de organizarse, creando un Controlador por cada Caso de Uso.
Creamos entonces nuestro CreateProductController.js
// CreateProductController.js
export const CreateProductController = (useCase) => {
const execute = async (req, res) => {
try {
const dto = {
name: req.body.name,
description: req.body.description,
price: req.body.price,
}
const newProduct = await useCase.execute(dto)
res.status(201).json(newProduct);
} catch (error) {
logger.error(`Error while creating a new product: ${error.message}`);
res.status(400).json({ message: error.message });
}
}
return { execute }
}
Ahora tenemos que instanciarlo, para ello, nuevamente creamos un index.js
// index.js
import { CreateProductController } from './CreateProductController';
import { createProductUseCase } from './usecases';
const createProductController = CreateProductController(createProductUseCase);
export { createProductController };
De aquí necesitamos una pequeña re-estructuración, lo primero es que para ordenarnos mejor, podemos dejar la implementación de nuestros Controladores y Casos de Uso en una misma carpeta, la cual indicará la intención de los archivos dentro de la misma.
Sugiero tomar como ejemplo la organización de carpetas a continuación, utilizada en el backend de los talleres OCILabs, donde CheckAttendance es el Caso de Uso, y CheckAttendanceController es el Controlador, index.ts
es el archivo encargado de instanciar ambos.
Lo segundo es que como ahora nuestros controladores no serán funciones anónimas, por lo que creamos (o modificamos) nuestro archivo de rutas, para que quede como lo siguiente
// routes.js
import { Router } from 'express';
import { createProductController } from '../useCases/CreateProduct'
const router = Router();
router.post('/products', createProductController.execute)
Si bien esto último es un plus, ayuda a tener también controladores testeables unitariamente y no terminar con archivos enormes con miles de métodos.
Conclusiones
Al aplicar estos pasos, hemos migrado de un caso común de MVC aplicado, a una arquitectura donde se facilita el testing unitario de cada una de las entidades involucradas, y además, se aplica un lenguaje ubicuo al añadir más entidades conocidas derivadas del DDD. Ahora bien, la aplicación constante de esta suerte de Singleton no es una Bala de Plata, pues es bien sabido que este es un Antipatrón, aunque hasta ahora con la aplicación propuesta, no he visto semejante problema.
Les invito a discutir al respecto, comentar las cosas que parezcan incongruentes o mejorables, siempre he encontrado que por mucho que lea sobre ingeniería de software, es la mirada de otras personas en discusiones la que permite avanzar más y más en este mundo. Todo comentario es bienvenido, y espero este post les haya sido de utilidad.
Top comments (0)