Estructura MVC completa (carpetas reales)
El concepto ya está documentado
Sección titulada «El concepto ya está documentado»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.
Árbol completo
Sección titulada «Árbol completo»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.tsrepositories/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.
Consideraciones
Sección titulada «Consideraciones»- 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
controlleraquí no tienetry/catchpropio porqueasyncHandlerya lo cubre (ver Manejo de errores centralizado) — cualquierthrowdeservicesube directo al error handler.