Introducción al backend con arquitectura en capas

Esta es una guía de introducción a arquitectura en capas utilizando estas tecnologias. Dicha arquitectura, proveerá de fácil abstracción, escalabilidad, y fácil mantención del mismo. A efectos prácticos, recomiendo leer el artículo enlazado anteriormente, previo a leer éste mini artículo que será de carácter meramente práctico.

Requisitos necesarios

Node (versión LTS)

Express

Mongoose

MongoDB

Postman

Crea tu base de datos de MongoDB

Crearemos una cuenta desde la web oficial, aceptaremos los términos y condiciones. Definiremos los datos básicos de nuestra aplicación y seleccionaremos la versión gratuita.

image.png

Una vez hemos creado nuestro cluster (esto puede tardar unos minutos), definiremos el usuario y contraseña de nuestra base de datos.

image.png

Una vez hemos creado nuestro usuario, debemos ir a Network Access (situado a la izquierda del panel de administración), y agregar una dirección IP.

image.png

Una vez tenemos nuestro cluster, usuario e IP, podemos ir a la sección Database (situada a la izquierda del panel, en la parte superior), y elegir el método de conexión. Para este caso, elegiremos “Connect your application”.

image.png

Esto nos mostrará la URL de conexión a nuestra base de datos. Sin embargo, no muestra la contraseña establecida anteriormente. Para configurarlo correctamente, debemos eliminar los símbolos de menor y mayor, y escribir el valor de la contraseña en lugar de la palabra password.

mongodb+srv://brahiblog:brahiblog@cluster0.hfisa.mongodb.net/?retryWrites=true&w=majority

(Esta es una url de prueba. Recuerda, JAMÁS exponer datos sensibles de una aplicación real).

Lectura: function vs class

El pensar en un backend con export functions, supone una carga innecesaria de exportaciones e importaciones. Por otro lado, al usar clases bastaria solo una exportación e importación de la misma para tener acceso a todos sus métodos. Veamos un ejemplo con funciones:

// export functions
function SayHello () {
    console.log("Hello");
}

function SayBye () {
    console.log("Bye");
}

module.exports = { SayHello, SayBye };
// import functions
const { SayHello, SayBye } = require('./export-function');

SayHello();
SayBye();

Ahora veamos un ejemplo con clases:

// export class
class Say {
    async hello () {
        console.log("Hello");
    }

    async bye () {
        console.log("Bye");
    }
}

module.exports = new Say();
// import class
const sayClass = require('./export-class');

sayClass.hello();
sayClass.bye();

Si ejecutamos respectivos archivos en nuestra consola, obtendremos el mismo resultado.

> node import-function
Hello
Bye
> node import-class
Hello
Bye

Habiendo visto esta sutil diferencia, vamos a definir la estructura a seguir.

Estructura a seguir

Arquitectura de 4 capas

src
│   index.ts        # App entry point
└───controllers     # route controllers for all the endpoints of the app
└───config          # Environment variables and configuration related stuff
└───models          # Database models
└───repositories    # Custom queries to database
└───services        # All the business logic is here
└───routes          # Express.js entry points to controllers

Problema a resolver

Una empresa está creciendo, y necesita un sistema donde pueda tener un registro de sus productos. Dentro de las funcionalidades que pide están, crear un nuevo producto, obtener todos los productos, obtener un producto específico, editar un producto específico y eliminar un producto específico. En otras palabras, se pide crear un CRUD de productos con sus respectivos endpoints.

Comenzamos a codear

Iniciar proyecto e instalación de dependencias Primero debemos inicializar nuestro proyecto de node, para ello, en terminal escribimos:

npm init --y

Se nos crearán dos archivos en nuestro proyecto (package.json y package-lock.json). Ahora debemos instalar las dependencias necesarias para construir nuestro servidor web.

npm i express cors mongoose dotenv

Express, es el framework que usaremos sobre Node. Cors es quien controla el acceso del protocolo HTTP, y Mongoose el ODM que usaremos para crear nuestros modelos de nuestra base de datos. Dotenv, será quien cargue las variables de entorno desde un archivo .env a nuestro process.env y almacenará dicha configuración.

Además, para facilitar la tarea de desarrollo usaremos una utilidad llamada nodemon, la que nos permitirá correr nuestro proyecto y refrescar los cambios de forma automática en tiempo real.

npm i nodemon -D

El flag -D indica que nodemon será utilizado como dependencia de desarrollo.

Configuraciones iniciales

Una vez tenemos nuestras dependencias instaladas, y conociendo la estructura que tendrá nuestro proyecto, podemos adelantarnos a configurar nodemon y la raíz de nuestro proyecto. Para ello, debemos ir a nuestro archivo llamado package.json

{
  "main": "src/index.js",
  "scripts": {
        "dev": "nodemon .",
    "start": "node ."
  },
}

Raiz de la aplicación

En el paso anterior, hemos configurado el punto de entrada de nuestra aplicación. Para que esto funcione, dentro de la carpeta src, crearemos un archivo llamado index.js

const express = require("express");
const mongoose = require("mongoose");
const cors = require("cors");

const config = require('./config');
const indexRoutes = require('./routes');

mongoose
  .connect(config.db.mongo)
  .then((res) => {

    const app = express();

    app.use(
      express.urlencoded({
        extended: true,
      })
    );

    app.use(express.json());

    app.use(cors());

    app.use("/", indexRoutes);

    app.listen(config.app.port, () => {
      console.log(`🔥 Server is running at port ${config.app.port}`);
    });

    console.log("Connected to MongoDB");
  })
  .catch((error) => console.log(error));

Configuración de variables de entorno

Se puede observar, que en nuestro punto de entrada de nuestra aplicación estamos utilizando valores provenientes de la carpeta config mediante template strings, sin embargo, aún no lo hemos configurado. Para realizarlo, dentro de la carpeta config, crearemos un archivo llamado index.js

const dotenv = require("dotenv");
dotenv.config();

const { MONGO_URL, PORT_APP } = process.env;

const config = {
  db: {
    mongo: MONGO_URL,
  },
  app: {
    port: PORT_APP,
  },
};

module.exports = config;

Estamos desestrucutrando los valores provenientes de process.env gracias a dotenv, sin embargo, aún no tenemos valores para extraer. Por ende, crearemos en la raíz de nuestra aplicación un archivo llamado .env

PORT_APP=3000
MONGO_URL=mongodb+srv://brahiblog:brahiblog@cluster0.hfisa.mongodb.net/?retryWrites=true&w=majority

En nuestras variables de entorno, irán todos aquellos datos de carácter sensible. Y estos, bajo ninguna circunstancia deben aparecer en nuestro árbol de git. Para solventar, podemos crear un archivo llamado .gitignore en la raíz de nuestra aplicación.

.env
/node_modules

Models

Una vez hemos definido nuestra configuración inicial, podemos definir nuestro modelo producto. Para ello, dentro de la carpeta models, crearemos un archivo llamado product-model.js

const mongoose = require("mongoose");
const { Schema } = mongoose;

const ProductSchema = new Schema({
  title: {
    type: String,
    required: [true, "The title is required"],
    unique: true,
  },
  price: {
    type: Number,
    required: [true, "The price is required"],
  },
  description: {
    type: String,
    required: [true, "The description is required"],
  },
});

module.exports = mongoose.model("Product", ProductSchema);

Repositories

Una vez nuestro modelo está definido con las propiedades que requerimos, podemos pensar en nuestro repositorio y en las queries hacia nuestra base de datos. Para ello, crearemos dentro de la carpeta repositories, un archivo llamado product.repository.js

const Product = require("../models/product.model");

class ProductRepository {
  async findAll() {
    return await Product.find();
  }

  async findById(id) {
    return await Product.findById(id);
  }

  async create(product) {
    return await Product.create(product);
  }

  async update(id, product) {
    return await Product.findOneAndUpdate({ _id: id }, product, { new: true });
  }

  async delete(id) {
    return await Product.deleteOne({ _id: id });
  }
}

module.exports = new ProductRepository();

En el método update de esta clase, el objeto { new: true }, indica que en la response, nos devolverá el objeto editado, es decir, el nuevo objeto después de hacer dicha petición (update).

Services

Una vez hemos definido nuestro repositorio, podemos continuar con nuestra lógica de negocios. Para ello, dentro de la carpeta services, crearemos un archivo llamado products.service.js

const productRepository = require('../repositories/product.repository');

class PostService {
  async findAll() {
    try {
      return await productRepository.findAll();
    } catch (error) {
      throw new Error(error);
    }
  }

  async findById(id) {
    try {
      return await productRepository.findById(id);
    } catch (error) {
      throw new Error(error);
    }
  }

  async create(product) {
    try {
      return await productRepository.create(product);
    } catch (error) {
      throw new Error(error);
    }
  }

  async update(id, product) {
    try {
      return await productRepository.update(id, product, { new: true });
    } catch (error) {
      throw new Error(error);
    }
  }

  async delete(id) {
    try {
      return await productRepository.delete(id);
    } catch (error) {
      throw new Error(error);
    }
  }
}

module.exports = new PostService();

Nuestra lógica de negocios, además de hacer validaciones, también hará manejo de errores.

Controllers

Una vez hemos definido nuestro servicio, podemos continuar con nuestro controlador, quien será el único encargado de comunicarse con el protocolo HTPP, la Request y la Response. Para ello, dentro de la carpeta controllers, creamos un archivo llamado products.controller.js

const productsService = require('../services/products.service');

class ProductsController {
  async findAll(req, res) {
    const products = await productsService.findAll();

    res.status(200).json(products);
  }

  async findById(req, res) {
    const { id } = req.params;

    const product = await productsService.findById(id);
    res.status(200).json(product);
  }

  async create(req, res) {
    const { title, price, description } = req.body;

    const product = await productsService.create({ title, price, description });
    res.status(201).json(product);
  }

  async update(req, res) {
    const { id } = req.params;
    const { title, price, description } = req.body;

    const product = await productsService.update(id, { title, price, description });
    res.status(200).json(product);
  }

  async delete(req, res) {
    const { id } = req.params;

    const product = await productsService.delete(id);
    res.status(200).json(product);
  }
}

module.exports = new ProductsController();

Se puede observar que cada método está retornando la respuesta con un status code (en algunos casos, distintos). Estos códigos se les denomina códigos de estado de respuesta HTTP.

Routes

Una vez hemos definido nuestro controlador, debemos construir los endpoints por los cuales se realizarán las peticiones a nuestra API, en otras palabras, definiremos rutas con sus respectivos métodos. Para ello, dentro de la carpeta routes, crearemos un archivo llamado products.route.js

const { Router } = require("express");
const routes = Router();

const productsController = require("../controllers/products.controller");

routes.get("/", productsController.findAll);
routes.get("/:id", productsController.findById);
routes.post("/create", productsController.create);
routes.put("/update/:id", productsController.update);
routes.delete("/delete/:id", productsController.delete);

module.exports = routes;

Una vez tenemos nuestra ruta de productos con sus respectivos métodos, crearemos un índice de rutas, considerando que nuestra aplicación podría escalar. Para ello, dentro de la carpeta routes, crearemos un archivo llamado index.js

const { Router } = require('express');
const routes = Router();

const productsRoutes = require('./products.route');

routes.use('/products', productsRoutes);

module.exports = routes;

En este punto, deberíamos poder levantar nuestra aplicación sin problemas.

> npm run dev

> introbackend@1.0.0 dev
> nodemon .

[nodemon] 2.0.15
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: js,mjs,json
[nodemon] starting `node .`
Connected to MongoDB
🔥 Server is running at port 3000

Testing con Postman

Una vez tenemos nuestra aplicación corriendo, podemos realizar el test correspondiente a cada endpoint para confirmar que efectivamente, funcionan como se espera. Si bien el orden que por convención se suele seguir, es:

findAll
findById
create
update
delete

A efectos prácticos, comenzaremos haciendo test de nuestro método POST del protocolo HTTP, en nuestra ruta /products/create mediante el método create de nuestro controlador.

image.png

Se puede observar la response, con un status code 201 (created). Y el json que se ha creado. Luego, haremos un test de el método GET del protocolo HTTP, en nuestra ruta default /products mediante el método findAll de nuestro controlador.

image.png

Luego, haremos un test de el método GET del protocolo HTTP, en nuestra ruta /products/:id mediante el método findById de nuestro controlador

image.png

Se puede observar la response, con un status code 200 (ok). Y el json que se ha obtenido. Luego, haremos test del método PUT del protocolo HTTP, en nuestra ruta /products/update/:id mediante el método update de nuestro controlador. Para ello, debemos agregar el ID del producto que deseamos editar en los parámetros de ruta, y también el el body del json a editar.

image.png

Se puede observar la response, con un status code 200 (ok). Y el nuevo json editado. Por último, haremos test del método DELETE del protocolo HTTP, en nuestra ruta /products/delete/:id mediante el método delete de nuestro controlador. Para ello, debemos agregar el ID del producto que deseamos eliminar en los parámetros de ruta.

image.png

Se puede observar la response, con un status code 200. Y el deletedCount 1.

Agradecimientos

Un especial agradecimiento a Scorpion y Franco.

¡Hasta pronto 👋!

Si deseas colaborar con el contenido, regalame un cafecito