Arquitectura en Capas con Express, Sequelize y PostgreSQL

Repositorio del Proyecto

El código completo de este tutorial está disponible en:GitHub Repository

Una nota antes de empezar: Seré honesto contigo. No soy la persona más fanática de la arquitectura en capas. En proyectos reales, siempre opto por arquitectura hexagonal (o puertos y adaptadores), que ofrece mayor flexibilidad y testabilidad.

Pero, este tutorial está diseñado específicamente para mis estudiantes que están dando sus primeros pasos en el mundo del desarrollo web backend. La arquitectura en capas es más fácil de entender cuando estás comenzando, y te prepara para conceptos más avanzados más adelante. Piensa en esto como aprender a caminar antes de correr. Una vez domines estos fundamentos, estarás listo para explorar patrones más sofisticados.

¿Qué vamos a construir?

En este tutorial completo, crearemos un sistema de facturación con Express.js, PostgreSQL y Sequelize. El proyecto está diseñado para estudiantes que están aprendiendo desarrollo web backend y quieren entender:

  • Arquitectura en capas (Controllers, Services, Repositories)
  • Diferencias entre SQL directo y ORM
  • Relaciones entre tablas (1:N, N:M)
  • Docker para desarrollo local
  • Buenas prácticas de código limpio

Stack Tecnológico

De arriba hacia abajo: cómo se conectan las tecnologías

Express.js

Framework minimalista de Node.js para construir APIs REST de forma rápida y flexible.

Sequelize ORM

Object-Relational Mapping para trabajar con bases de datos usando JavaScript. Simplifica las queries y maneja relaciones automáticamente.

pg (node-postgres)

Driver nativo de PostgreSQL para escribir SQL directo. Te permite tener control total sobre tus queries.

PostgreSQL

Base de datos relacional open-source, robusta y confiable. Corriendo en Docker para facilitar el desarrollo local.

El flujo de datos va de arriba hacia abajo

Docker

Contenerización para ejecutar PostgreSQL sin instalación compleja. Facilita el setup del ambiente de desarrollo.

Arquitectura en Capas

Separación de responsabilidades: Routes → Services → Repositories. Cada capa tiene un propósito específico.

¿Por qué estas tecnologías?

Este stack es muy común en la industria. PostgreSQL es robusto y gratuito, Express es el framework más popular de Node.js, y Sequelize facilita trabajar con bases de datos sin escribir SQL (aunque aprenderás ambas formas).

¿Qué es la Arquitectura en Capas?

La arquitectura en capas es un patrón de diseño que separa tu aplicación en capas independientes, cada una con una responsabilidad específica. Esto hace que tu código sea:

Mantenible

Cada capa tiene su propia responsabilidad. Los cambios en una capa no afectan a las demás.

Testeable

Puedes probar cada capa por separado sin necesidad de la base de datos real.

Escalable

Fácil agregar nuevas funcionalidades sin romper el código existente.

Las 3 Capas de Nuestra Arquitectura

1. Controllers (Routes)

Responsabilidad: Manejar las peticiones HTTP (GET, POST, PUT, DELETE)

➜ Recibe la petición del cliente
➜ Valida parámetros básicos
➜ Llama al Service correspondiente
➜ Devuelve la respuesta (JSON)

2. Services

Responsabilidad: Lógica de negocio y reglas de validación

➜ Valida datos de negocio
➜ Orquesta operaciones complejas
➜ Llama a uno o más Repositories
➜ Lanza excepciones si algo falla

3. Repositories

Responsabilidad: Acceso directo a la base de datos

➜ Operaciones CRUD (Create, Read, Update, Delete)
➜ Queries a la base de datos
➜ NO tiene lógica de negocio
➜ Independiente del ORM o SQL usado

Configuración Inicial del Proyecto

Prerequisitos

Antes de empezar, asegúrate de tener instalado:
  • Node.js (versión 16 o superior)
  • Docker y Docker Compose
  • Un editor de código (VS Code recomendado)
  • Postman o Thunder Client para probar la API

1. Estructura del Proyecto

bash
1mkdir express-architecture
2cd express-architecture
3npm init -y

2. Instalar Dependencias

bash
1npm install express pg sequelize

Donde:

  • express - Framework web para Node.js
  • pg - Driver de PostgreSQL para SQL directo
  • sequelize - ORM para manejar la base de datos con JavaScript

3. Configurar Docker Compose

Crea un archivo docker-compose.yml en la raíz del proyecto:

docker-compose.yml
1version: '3.8'
2
3services:
4  postgres:
5    image: postgres:16-alpine
6    container_name: express_002_postgres
7    environment:
8      POSTGRES_USER: postgres
9      POSTGRES_PASSWORD: postgres
10      POSTGRES_DB: products_db
11    ports:
12      - "5433:5432"  # Puerto personalizado para evitar conflictos
13    volumes:
14      - postgres_data:/var/lib/postgresql/data
15      - ./init.sql:/docker-entrypoint-initdb.d/init.sql
16
17volumes:
18  postgres_data:

¿Por qué puerto 5433?

Usamos el puerto 5433 en lugar del 5432 por defecto para evitar conflictos si ya tienes PostgreSQL instalado en tu máquina.

4. Script Inicial de la Base de Datos

Crea init.sql con las tablas iniciales:

init.sql
1-- Tabla de Usuarios
2CREATE TABLE users (
3    id SERIAL PRIMARY KEY,
4    name VARCHAR(100) NOT NULL,
5    email VARCHAR(100) UNIQUE NOT NULL,
6    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
7);
8
9-- Tabla de Productos
10CREATE TABLE products (
11    id SERIAL PRIMARY KEY,
12    description VARCHAR(200) NOT NULL,
13    price DECIMAL(10,2) NOT NULL,
14    stock INTEGER DEFAULT 0,
15    sku VARCHAR(50) UNIQUE NOT NULL,
16    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
17);
18
19-- Tabla de Facturas
20CREATE TABLE invoices (
21    id SERIAL PRIMARY KEY,
22    user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
23    total DECIMAL(10,2) NOT NULL,
24    invoice_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
25    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
26);
27
28-- Tabla débil: Items de Factura (relación N:M)
29CREATE TABLE invoice_items (
30    id SERIAL PRIMARY KEY,
31    invoice_id INTEGER REFERENCES invoices(id) ON DELETE CASCADE,
32    product_id INTEGER REFERENCES products(id) ON DELETE CASCADE,
33    quantity INTEGER NOT NULL,
34    unit_price DECIMAL(10,2) NOT NULL,
35    subtotal DECIMAL(10,2) NOT NULL,
36    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
37);
38
39-- Datos de ejemplo
40INSERT INTO users (name, email) VALUES 
41('Jorge Saavedra', 'jorge@example.com'),
42('Ana García', 'ana@example.com');
43
44INSERT INTO products (description, price, stock, sku) VALUES 
45('Laptop Dell XPS', 1299.99, 10, 'LAP-DELL-001'),
46('Mouse Logitech', 29.99, 50, 'MOU-LOG-002'),
47('Teclado Mecánico', 89.99, 25, 'KEY-MEC-003');

5. Iniciar PostgreSQL

bash
1# Iniciar PostgreSQL
2docker-compose up -d
3
4# Verificar que esté corriendo
5docker ps
6
7# Conectarse a la base de datos (opcional)
8docker exec -it express_002_postgres psql -U postgres -d products_db

SQL Directo vs ORM: Dos Formas de Trabajar

Una de las características únicas de este proyecto es que implementaremos la misma funcionalidad de dos formas diferentes: usando SQL directo (con el driver pg) y usando un ORM (Sequelize).

¿Por qué aprender ambas?

Como estudiante, es crucial que entiendas cómo funcionan las bases de datos por dentro(SQL directo) antes de usar abstracciones (ORM). Una vez domines SQL, los ORMs te harán mucho más productivo.

Característica

Control

SQL Directo (pg)

✓ Total control sobre queries

ORM (Sequelize)

⚬ Abstracción puede limitar

Característica

Curva de aprendizaje

SQL Directo (pg)

⚬ Necesitas saber SQL

ORM (Sequelize)

✓ Más fácil de empezar

Característica

Productividad

SQL Directo (pg)

⚬ Más código manual

ORM (Sequelize)

✓ Menos código, más rápido

Característica

Debugging

SQL Directo (pg)

✓ Ves exactamente el SQL

ORM (Sequelize)

⚬ Queries generadas automáticamente

Característica

Migraciones

SQL Directo (pg)

⚬ Manual

ORM (Sequelize)

✓ Automáticas

Característica

Relaciones

SQL Directo (pg)

⚬ JOINs manuales

ORM (Sequelize)

✓ Includes automáticos

Característica

Casos de uso

SQL Directo (pg)

Queries complejas, optimización

ORM (Sequelize)

CRUD rápido, prototipado

Ejemplo Comparativo: Obtener Todos los Productos

SQL Directo

javascript
1// productRepository.js
2class ProductRepository {
3    constructor(pool) {
4        this.pool = pool;
5    }
6
7    async findAll() {
8        const result = await this.pool.query(
9            'SELECT * FROM products ORDER BY id'
10        );
11        return result.rows;
12    }
13
14    async findById(id) {
15        const result = await this.pool.query(
16            'SELECT * FROM products WHERE id = $1',
17            [id]
18        );
19        return result.rows[0];
20    }
21}

ORM (Sequelize)

javascript
1// productRepositoryORM.js
2class ProductRepositoryORM {
3    constructor(model) {
4        this.model = model;
5    }
6
7    async findAll() {
8        return await this.model.findAll({
9            order: [['id', 'ASC']]
10        });
11    }
12
13    async findById(id) {
14        return await this.model.findByPk(id);
15    }
16}

Como puedes ver, con SQL directo escribes la query completa, mientras que conSequelize usas métodos de JavaScript que generan el SQL por ti.

Definiendo Modelos con Sequelize

Los modelos son clases de JavaScript que representan tablas de la base de datos. Sequelize nos permite definir la estructura de nuestras tablas usando código. Ver documentación de Modelos →

1. Configuración de Sequelize

database.js
1const { Sequelize } = require('sequelize');
2
3const sequelize = new Sequelize('products_db', 'postgres', 'postgres', {
4    host: 'localhost',
5    port: 5433,
6    dialect: 'postgres',
7    logging: false, // Desactiva los logs SQL en consola
8    pool: {
9        max: 5,
10        min: 0,
11        acquire: 30000,
12        idle: 10000
13    }
14});
15
16// Probar la conexión
17async function testConnection() {
18    try {
19        await sequelize.authenticate();
20        console.log('✓ Conexión a PostgreSQL exitosa');
21    } catch (error) {
22        console.error('❌ Error conectando a PostgreSQL:', error);
23    }
24}
25
26testConnection();
27
28module.exports = sequelize;

2. Modelo de Producto

models/Product.js
1const { DataTypes } = require('sequelize');
2const sequelize = require('../database');
3
4const Product = sequelize.define('Product', {
5    id: {
6        type: DataTypes.INTEGER,
7        primaryKey: true,
8        autoIncrement: true
9    },
10    description: {
11        type: DataTypes.STRING(200),
12        allowNull: false
13    },
14    price: {
15        type: DataTypes.DECIMAL(10, 2),
16        allowNull: false
17    },
18    stock: {
19        type: DataTypes.INTEGER,
20        defaultValue: 0
21    },
22    sku: {
23        type: DataTypes.STRING(50),
24        allowNull: false,
25        unique: true
26    }
27}, {
28    tableName: 'products',
29    timestamps: true,
30    createdAt: 'created_at',
31    updatedAt: false
32});
33
34module.exports = Product;

Sincronización de Modelos

En este proyecto, NO usamos sequelize.sync() porque ya tenemos las tablas creadas con init.sql. Sequelize solo se conecta a tablas existentes. En proyectos nuevos, podrías usar sync() o migraciones.

Relaciones entre Tablas: El Corazón del Sistema

Nuestro sistema de facturación tiene cuatro tablas relacionadas. Entender estas relaciones es fundamental para trabajar con bases de datos relacionales. Ver documentación de Asociaciones →

Diagrama de Relaciones

Leyenda:

PK =Primary Key (Clave Primaria)
FK =Foreign Key (Clave Foránea)
UK =Unique Key (Único)
||--o{ =Relación Uno a Muchos (1:N)

Tipos de Relaciones

1:N - Un Usuario tiene Muchas Facturas

Un usuario puede tener múltiples facturas, pero cada factura pertenece a un solo usuario.

javascript
1// En associations.js
2User.hasMany(Invoice, { 
3    foreignKey: 'user_id',
4    as: 'invoices' 
5});
6
7Invoice.belongsTo(User, { 
8    foreignKey: 'user_id',
9    as: 'user' 
10});

N:M - Facturas y Productos (a través de invoice_items)

Una factura puede tener muchos productos, y un producto puede estar en muchas facturas. Esta relación se implementa con una tabla débil(invoice_items).

javascript
1// En associations.js
2Invoice.belongsToMany(Product, {
3    through: InvoiceItem,
4    foreignKey: 'invoice_id',
5    otherKey: 'product_id',
6    as: 'products'
7});
8
9Product.belongsToMany(Invoice, {
10    through: InvoiceItem,
11    foreignKey: 'product_id',
12    otherKey: 'invoice_id',
13    as: 'invoices'
14});

¿Qué es una Tabla Débil?

invoice_items es una tabla débil porque:
  • No tiene sentido por sí sola (necesita una factura Y un producto)
  • Depende de otras tablas para existir
  • Si eliminas una factura, sus items también se eliminan (ON DELETE CASCADE)
  • Almacena información adicional de la relación (cantidad, precio en ese momento, subtotal)

Consultando con Relaciones (include)

Una de las ventajas de Sequelize es que puedes cargar relaciones automáticamentesin escribir JOINs manuales. Aprende más sobre Eager Loading →

Ejemplo: Obtener factura con usuario y productos
1// Obtener una factura con todos sus datos relacionados
2const invoice = await Invoice.findByPk(1, {
3    include: [
4        {
5            model: User,
6            as: 'user',
7            attributes: ['id', 'name', 'email']
8        },
9        {
10            model: InvoiceItem,
11            as: 'items',
12            include: [{
13                model: Product,
14                as: 'product'
15            }]
16        }
17    ]
18});
19
20// Resultado:
21{
22    id: 1,
23    total: 1419.97,
24    invoice_date: '2025-01-31T...',
25    user: {
26        id: 1,
27        name: 'Jorge Saavedra',
28        email: 'jorge@example.com'
29    },
30    items: [
31        {
32            quantity: 1,
33            unit_price: 1299.99,
34            subtotal: 1299.99,
35            product: {
36                description: 'Laptop Dell XPS',
37                price: 1299.99,
38                sku: 'LAP-DELL-001'
39            }
40        },
41        {
42            quantity: 4,
43            unit_price: 29.99,
44            subtotal: 119.98,
45            product: {
46                description: 'Mouse Logitech',
47                price: 29.99,
48                sku: 'MOU-LOG-002'
49            }
50        }
51    ]
52}

Implementando Repositorios

Los repositorios son la capa que accede directamente a la base de datos. Implementaremos dos versiones del repositorio de productos para que entiendas ambas formas.

Repositorio con SQL Directo

repositories/productRepository.js
1const { Pool } = require('pg');
2
3class ProductRepository {
4    constructor() {
5        this.pool = new Pool({
6            user: 'postgres',
7            host: 'localhost',
8            database: 'products_db',
9            password: 'postgres',
10            port: 5433,
11        });
12    }
13
14    async findAll() {
15        const result = await this.pool.query(
16            'SELECT * FROM products ORDER BY id'
17        );
18        return result.rows;
19    }
20
21    async findById(id) {
22        const result = await this.pool.query(
23            'SELECT * FROM products WHERE id = $1',
24            [id]
25        );
26        return result.rows[0];
27    }
28
29    async create(productData) {
30        const { description, price, stock, sku } = productData;
31        const result = await this.pool.query(
32            `INSERT INTO products (description, price, stock, sku) 
33             VALUES ($1, $2, $3, $4) 
34             RETURNING *`,
35            [description, price, stock, sku]
36        );
37        return result.rows[0];
38    }
39
40    async update(id, productData) {
41        const { description, price, stock, sku } = productData;
42        const result = await this.pool.query(
43            `UPDATE products 
44             SET description = $1, price = $2, stock = $3, sku = $4 
45             WHERE id = $5 
46             RETURNING *`,
47            [description, price, stock, sku, id]
48        );
49        return result.rows[0];
50    }
51
52    async delete(id) {
53        await this.pool.query('DELETE FROM products WHERE id = $1', [id]);
54        return true;
55    }
56}
57
58module.exports = new ProductRepository();

Repositorio con Sequelize (ORM)

repositories/productRepositoryORM.js
1const Product = require('../models/Product');
2
3class ProductRepositoryORM {
4    async findAll() {
5        return await Product.findAll({
6            order: [['id', 'ASC']]
7        });
8    }
9
10    async findById(id) {
11        return await Product.findByPk(id);
12    }
13
14    async create(productData) {
15        return await Product.create(productData);
16    }
17
18    async update(id, productData) {
19        const product = await Product.findByPk(id);
20        if (!product) return null;
21        
22        return await product.update(productData);
23    }
24
25    async delete(id) {
26        const product = await Product.findByPk(id);
27        if (!product) return false;
28        
29        await product.destroy();
30        return true;
31    }
32}
33
34module.exports = new ProductRepositoryORM();

Firma de Métodos Idéntica

Nota cómo ambos repositorios tienen exactamente los mismos métodos con los mismos parámetros. Esto te permite cambiar entre SQL directo y ORM sin modificar el resto de tu código.

Capa de Servicios: La Lógica de Negocio

Los servicios contienen la lógica de negocio. Aquí es donde validamos datos, manejamos errores y orquestamos operaciones que pueden involucrar múltiples repositorios.

services/productService.js
1// Puedes cambiar entre ambos repositorios aquí:
2const productRepository = require('../repositories/productRepositoryORM');
3// const productRepository = require('../repositories/productRepository');
4
5class ProductService {
6    async getAllProducts() {
7        try {
8            return await productRepository.findAll();
9        } catch (error) {
10            throw new Error('Error obteniendo productos: ' + error.message);
11        }
12    }
13
14    async getProductById(id) {
15        // Validación de negocio
16        if (!id || isNaN(id)) {
17            throw new Error('ID de producto inválido');
18        }
19
20        const product = await productRepository.findById(id);
21        
22        if (!product) {
23            throw new Error(`Producto con ID ${id} no encontrado`);
24        }
25
26        return product;
27    }
28
29    async createProduct(productData) {
30        // Validaciones de negocio
31        if (!productData.description || !productData.price || !productData.sku) {
32            throw new Error('Faltan campos obligatorios');
33        }
34
35        if (productData.price <= 0) {
36            throw new Error('El precio debe ser mayor a 0');
37        }
38
39        if (productData.stock < 0) {
40            throw new Error('El stock no puede ser negativo');
41        }
42
43        return await productRepository.create(productData);
44    }
45
46    async updateProduct(id, productData) {
47        // Verificar que el producto existe
48        await this.getProductById(id);
49
50        // Validaciones de negocio
51        if (productData.price && productData.price <= 0) {
52            throw new Error('El precio debe ser mayor a 0');
53        }
54
55        if (productData.stock && productData.stock < 0) {
56            throw new Error('El stock no puede ser negativo');
57        }
58
59        return await productRepository.update(id, productData);
60    }
61
62    async deleteProduct(id) {
63        // Verificar que el producto existe
64        await this.getProductById(id);
65
66        return await productRepository.delete(id);
67    }
68}
69
70module.exports = new ProductService();

Separación de Responsabilidades

Los servicios se encargan de la lógica de negocio (validar precio > 0, stock no negativo), mientras que los repositorios solo se encargan de guardar/recuperar datos.

Controllers (Routes): Manejando Peticiones HTTP

Los controllers (en Express llamados "routes") son la capa más externa. Reciben las peticiones HTTP del cliente, llaman al servicio correspondiente y devuelven la respuesta.

routes/productRoutes.js
1const express = require('express');
2const router = express.Router();
3const productService = require('../services/productService');
4
5// GET /api/products - Obtener todos los productos
6router.get('/', async (req, res) => {
7    try {
8        const products = await productService.getAllProducts();
9        res.json(products);
10    } catch (error) {
11        res.status(500).json({ 
12            error: error.message 
13        });
14    }
15});
16
17// GET /api/products/:id - Obtener un producto por ID
18router.get('/:id', async (req, res) => {
19    try {
20        const product = await productService.getProductById(req.params.id);
21        res.json(product);
22    } catch (error) {
23        res.status(404).json({ 
24            error: error.message 
25        });
26    }
27});
28
29// POST /api/products - Crear un nuevo producto
30router.post('/', async (req, res) => {
31    try {
32        const newProduct = await productService.createProduct(req.body);
33        res.status(201).json(newProduct);
34    } catch (error) {
35        res.status(400).json({ 
36            error: error.message 
37        });
38    }
39});
40
41// PUT /api/products/:id - Actualizar un producto
42router.put('/:id', async (req, res) => {
43    try {
44        const updatedProduct = await productService.updateProduct(
45            req.params.id, 
46            req.body
47        );
48        res.json(updatedProduct);
49    } catch (error) {
50        res.status(400).json({ 
51            error: error.message 
52        });
53    }
54});
55
56// DELETE /api/products/:id - Eliminar un producto
57router.delete('/:id', async (req, res) => {
58    try {
59        await productService.deleteProduct(req.params.id);
60        res.status(204).send();
61    } catch (error) {
62        res.status(404).json({ 
63            error: error.message 
64        });
65    }
66});
67
68module.exports = router;

Servidor Principal

server.js
1const express = require('express');
2const productRoutes = require('./routes/productRoutes');
3
4const app = express();
5const PORT = 4000;
6
7// Middleware
8app.use(express.json());
9
10// Routes
11app.use('/api/products', productRoutes);
12
13// Servidor
14app.listen(PORT, () => {
15    console.log(`🚀 Servidor corriendo en http://localhost:${PORT}`);
16    console.log(`Endpoints disponibles:`);
17    console.log(`   GET    /api/products`);
18    console.log(`   GET    /api/products/:id`);
19    console.log(`   POST   /api/products`);
20    console.log(`   PUT    /api/products/:id`);
21    console.log(`   DELETE /api/products/:id`);
22});

Probando la API

1. Iniciar el Servidor

bash
1# Terminal 1: Asegúrate de que PostgreSQL esté corriendo
2docker-compose up -d
3
4# Terminal 2: Inicia el servidor Express
5npm start

2. Probar con cURL

Obtener todos los productos

bash
1curl http://localhost:4000/api/products

Obtener un producto por ID

bash
1curl http://localhost:4000/api/products/1

Crear un nuevo producto

bash
1curl -X POST http://localhost:4000/api/products \
2  -H "Content-Type: application/json" \
3  -d '{
4    "description": "Monitor LG 27 pulgadas",
5    "price": 299.99,
6    "stock": 15,
7    "sku": "MON-LG-005"
8  }'

Actualizar un producto

bash
1curl -X PUT http://localhost:4000/api/products/1 \
2  -H "Content-Type: application/json" \
3  -d '{
4    "price": 1199.99,
5    "stock": 8
6  }'

Eliminar un producto

bash
1curl -X DELETE http://localhost:4000/api/products/1

Usando Postman o Thunder Client

Si prefieres una interfaz gráfica, puedes usar Postman o la extensiónThunder Client de VS Code para probar los endpoints de forma más visual.

Extendiendo el Sistema: Facturas e Items

Ahora que entiendes la arquitectura básica, podemos agregar más entidades. El proyecto incluye un sistema completo de facturación con users, invoices e invoice_items.

Repositorio de Facturas (con relaciones)

repositories/invoiceRepository.js
1const { Invoice, User, InvoiceItem, Product } = require('../models');
2
3class InvoiceRepository {
4    // Obtener todas las facturas con usuario e items
5    async findAll() {
6        return await Invoice.findAll({
7            include: [
8                {
9                    model: User,
10                    as: 'user',
11                    attributes: ['id', 'name', 'email']
12                },
13                {
14                    model: InvoiceItem,
15                    as: 'items',
16                    include: [{
17                        model: Product,
18                        as: 'product'
19                    }]
20                }
21            ],
22            order: [['id', 'DESC']]
23        });
24    }
25
26    // Obtener una factura por ID (con todas sus relaciones)
27    async findById(id) {
28        return await Invoice.findByPk(id, {
29            include: [
30                {
31                    model: User,
32                    as: 'user'
33                },
34                {
35                    model: InvoiceItem,
36                    as: 'items',
37                    include: [{
38                        model: Product,
39                        as: 'product'
40                    }]
41                }
42            ]
43        });
44    }
45
46    // Crear una factura
47    async create(invoiceData) {
48        return await Invoice.create(invoiceData);
49    }
50
51    // Obtener facturas de un usuario específico
52    async findByUser(userId) {
53        return await Invoice.findAll({
54            where: { user_id: userId },
55            include: [
56                {
57                    model: InvoiceItem,
58                    as: 'items',
59                    include: [{
60                        model: Product,
61                        as: 'product'
62                    }]
63                }
64            ]
65        });
66    }
67}
68
69module.exports = new InvoiceRepository();

Nota sobre las Relaciones

Con Sequelize, las relaciones se cargan automáticamente usando include. No necesitas escribir JOINs manualmente como en SQL directo.

Mejores Prácticas y Consejos

1. Manejo de Errores

  • Siempre usa try/catch en funciones async
  • Devuelve códigos de estado HTTP apropiados (200, 201, 400, 404, 500)
  • Incluye mensajes de error descriptivos
  • Nunca expongas detalles internos de la DB al cliente

2. Validaciones

  • Valida datos en la capa de servicios
  • Valida tipos, rangos y formatos
  • Usa librerías como joi o yup para validaciones complejas
  • No confíes en validaciones del cliente (siempre valida en el servidor)

3. Seguridad

  • Usa prepared statements ($1, $2) para evitar SQL injection
  • Sanitiza inputs del usuario
  • Usa variables de entorno para credenciales (no las hardcodees)
  • Implementa autenticación y autorización (JWT, sesiones)

4. Performance

  • Usa connection pooling (ya incluido en este proyecto)
  • Limita los campos que cargas con attributes
  • Usa índices en la base de datos para búsquedas frecuentes
  • Implementa paginación para listas largas

5. Testing

  • Escribe tests unitarios para servicios
  • Usa mocks para los repositorios en tests
  • Implementa tests de integración para la API completa
  • Usa herramientas como Jest o Mocha

Ejercicios Propuestos

Para reforzar tu aprendizaje, te propongo los siguientes ejercicios:

Ejercicio 1: Agregar Categorías

Crea una nueva entidad Category y relacionala con Product (1:N):

  • • Crea la tabla categories
  • • Crea el modelo Sequelize
  • • Implementa repository, service y routes completos
  • • Agrega un campo category_id en products

Ejercicio 2: Validaciones Avanzadas

Mejora las validaciones del ProductService:

  • • Valida que el SKU tenga un formato específico (ej: XXX-XXX-###)
  • • Verifica que no exista un producto con el mismo SKU al crear
  • • Implementa validación de stock mínimo
  • • Agrega logs de todas las operaciones

Ejercicio 3: Reportes de Ventas

Crea endpoints para reportes:

  • • Total de ventas por usuario
  • • Productos más vendidos
  • • Ingresos totales en un rango de fechas
  • • Implementa las queries tanto en SQL directo como en Sequelize

Ejercicio 4: Actualización de Stock

Implementa lógica para actualizar el stock automáticamente:

  • • Al crear una factura, reduce el stock de los productos
  • • Valida que haya stock suficiente antes de crear la factura
  • • Implementa una transacción para garantizar consistencia
  • • Agrega un endpoint para reponer stock

Ejercicio 5: Migraciones con Sequelize

Aprende a usar migraciones: Ver guía de Migraciones →

  • • Instala sequelize-cli
  • • Crea migraciones para todas las tablas
  • • Implementa seeders para datos de prueba
  • • Documenta el proceso de setup usando migraciones

Recursos Adicionales

🎓 Temas para Profundizar

  • • Autenticación con JWT
  • • Tests con Jest y Supertest
  • • Documentación con Swagger
  • • Migraciones y Seeders
  • • Deploy en producción (Railway, Render)
  • • CI/CD con GitHub Actions

Conclusión

¡Felicitaciones! Has aprendido a construir una API REST completa con arquitectura en capas. Este proyecto te ha enseñado:

Arquitectura en capas (Controllers, Services, Repositories)
SQL directo vs ORM (Sequelize)
Relaciones entre tablas (1:N y N:M)
Docker para desarrollo local
Endpoints REST (GET, POST, PUT, DELETE)
Buenas prácticas y código limpio

Próximos Pasos

Para continuar tu aprendizaje:

  • 1. Clona el repositorio y experimenta con el código
  • 2. Completa los ejercicios propuestos
  • 3. Agrega autenticación con JWT
  • 4. Escribe tests para tu código
  • 5. Deploya tu API en producción
Express.jsPostgreSQLSequelizeDockerArquitectura en CapasAPI REST