Ir al contenido
angel.library

Estructura MVC completa (carpetas reales)

Esta receta lleva a código la Estructura MVC para APIs Express: conserva el árbol y el flujo de una request, con un recurso posts implementado en cada capa.

src/
├── app.ts
├── server.ts
├── routes/posts.routes.ts
├── controllers/posts.controller.ts
├── services/posts.service.ts
├── repositories/posts.repository.ts
├── middlewares/error-handler.ts
└── errors/AppError.ts

repositories/posts.repository.ts — solo acceso a datos

Sección titulada «repositories/posts.repository.ts — solo acceso a datos»
import { prisma } from "../lib/prisma"
export const postsRepository = {
findAll: () => prisma.post.findMany(),
findById: (id: string) => prisma.post.findUnique({ where: { id } }),
create: (data: { title: string; content?: string }) =>
prisma.post.create({ data }),
delete: (id: string) => prisma.post.delete({ where: { id } })
}

services/posts.service.ts — reglas de negocio

Sección titulada «services/posts.service.ts — reglas de negocio»
import { AppError } from "../errors/AppError"
import { postsRepository } from "../repositories/posts.repository"
export const postsService = {
async obtenerPost(id: string) {
const post = await postsRepository.findById(id)
if (!post)
throw new AppError(404, "POST_NO_ENCONTRADO", "Post no encontrado")
return post
},
async crearPost(datos: { title: string; content?: string }) {
if (datos.title.trim().length === 0) {
throw new AppError(400, "TITULO_VACIO", "El título no puede estar vacío")
}
return postsRepository.create(datos)
}
}

controllers/posts.controller.ts — única capa que conoce req/res

Sección titulada «controllers/posts.controller.ts — única capa que conoce req/res»
import type { Request, Response } from "express"
import { postsService } from "../services/posts.service"
export const postsController = {
async getPost(req: Request, res: Response) {
const post = await postsService.obtenerPost(req.params.id)
res.json(post)
},
async createPost(req: Request, res: Response) {
const post = await postsService.crearPost(req.body)
res.status(201).json(post)
}
}

routes/posts.routes.ts — solo mapeo ruta → controller

Sección titulada «routes/posts.routes.ts — solo mapeo ruta → controller»
import { Router } from "express"
import { postsController } from "../controllers/posts.controller"
import { asyncHandler } from "../utils/asyncHandler"
export const postsRouter = Router()
postsRouter.get("/:id", asyncHandler(postsController.getPost))
postsRouter.post("/", asyncHandler(postsController.createPost))

Por qué separar así (recordatorio del patrón)

Sección titulada «Por qué separar así (recordatorio del patrón)»

Cambiar de Prisma a otro ORM toca solo repositories/. Cambiar una regla de negocio (por ejemplo, longitud mínima del título) toca solo services/. Cambiar cómo se lee req.body o el status code de la respuesta toca solo controllers/. Cada capa tiene una sola razón para cambiar — ver el resto del razonamiento en el doc de arquitectura.

  • Para un proyecto chico (un CRUD, un prototipo), cuatro capas por cada recurso puede sentirse como overhead — el patrón se justifica cuando el proyecto crece lo suficiente como para que mezclar todo en un archivo empiece a doler. No hace falta empezar así desde el día uno de un proyecto trivial.
  • El controller aquí no tiene try/catch propio porque asyncHandler ya lo cubre (ver Manejo de errores centralizado) — cualquier throw de service sube directo al error handler.