Estructura de un proyecto Express.js | Nicolás Garzón
La estructura de un proyecto debe hacer visibles las responsabilidades y dependencias reales. Una carpeta no crea arquitectura, pero puede ayudar a preservarla.
Express no impone organización. El objetivo es que un cambio de negocio afecte una zona predecible y que el transporte HTTP no domine todo el código.
Una estructura por feature puede ser:
Texto
Copiar src/
app/
create-app.ts
error-handler.ts
config/
load-config.ts
modules/
orders/
http/
orders.router.ts
create-order.handler.ts
create-order.schema.ts
order.response.ts
application/
create-order.ts
cancel-order.ts
domain/
order.ts
order-errors.ts
infrastructure/
postgres-order.repository.ts
shared/
database/
logging/
server.tsEstructuras globales como:
Texto
Copiar controllers/
services/
repositories/
models/parecen ordenadas al inicio, pero dispersan una feature entre muchas carpetas. Para cambiar pedidos debes saltar por todo el proyecto.
Agrupar por feature mantiene juntos contratos relacionados y reduce imports cruzados.
Conoce Express, request, response, status, headers y schemas de transporte.
Coordina casos de uso, transacciones y dependencias mediante contratos explícitos.
Contiene reglas y conceptos que pueden expresarse sin Express ni PostgreSQL. No todo proyecto necesita un dominio rico.
Implementa DB, gateways, queues y detalles externos.
Las capas son una herramienta. Una aplicación CRUD pequeña puede combinar application/domain sin perder claridad.
Texto
Copiar HTTP → application → domain
infrastructure → contracts de application/domainEl caso de uso no debería importar Express. El repository PostgreSQL implementa una interfaz que la aplicación consume.
No conviertas esta dirección en ceremonias: TypeScript interfaces solo aportan valor cuando existe sustitución, test o boundary real.
Cada feature puede exponer una factory:
TypeScript
Copiar export function createOrdersModule ( deps: OrdersDependencies) {
const createOrder = new CreateOrder ( deps) ;
return {
router: createOrdersRouter ( { createOrder } ) ,
} ;
} El composition root importa esta API, no archivos internos arbitrarios.
server.ts o una factory superior conecta implementaciones:
TypeScript
Copiar const ordersRepository = new PostgresOrderRepository ( pool) ;
const ordersModule = createOrdersModule ( {
orders: ordersRepository,
clock,
ids,
} ) ;
app. use ( '/orders' , ordersModule. router) ; La dependencia es visible y centralizada.
Una carpeta shared suele acumular utilidades sin dueño. Mueve allí solo piezas realmente transversales y estables:
Logger.
Pool.
Resultados/errores base limitados.
Helpers HTTP comunes.
Una regla de pedidos no es shared porque otro módulo la llamó una vez.
No existe número mágico. Divide cuando:
Existen responsabilidades diferentes.
Una parte tiene tests propios.
Cambia por razones distintas.
La API interna puede nombrarse claramente.
No crees un archivo por función trivial si aumenta navegación sin aportar boundary.
Nombres orientados a intención:
Texto
Copiar create-order.ts
reserve-inventory.ts
map-order-error.tsTexto
Copiar order-service.ts
utils.ts
helper.tsLos nombres genéricos esconden responsabilidad.
Input HTTP validado.
Comando del caso de uso.
Entidad o modelo de dominio.
Row de PostgreSQL.
Response DTO.
Pueden compartir forma en una app pequeña, pero no deben quedar acoplados por accidente.
Cada módulo recibe configuración relevante, no process.env completo. Esto facilita tests y evita dependencias ocultas.
Los errores de dominio viven junto al módulo. El mapper HTTP central conoce categorías o códigos, no detalles internos de cada query.
Evita una jerarquía universal de 30 clases si simples discriminated unions expresan mejor el resultado.
Texto
Copiar orders.router.ts
orders.handlers.ts
orders.repository.tsSepara application, HTTP e infrastructure.
Evalúa límites más fuertes, packages o servicios.
No empieces con microservicios para resolver una carpeta grande.
La creación de pedidos cambia para emitir outbox:
create-order.ts coordina.
postgres-order.repository.ts persiste.
orders.router.ts no cambia salvo contrato.
Tests unitarios del caso de uso y tests de integración de repository cubren el cambio.
La estructura reduce el radio de impacto.
Usa herramientas/lint para detectar ciclos. Los ciclos suelen indicar módulos sin dirección clara o un shared mal definido.
No resuelvas todos los ciclos moviendo código a common; identifica ownership.
Un monorepo puede separar packages de contratos, SDK o módulos compartidos, pero añade build y versionado. No es requisito para una buena estructura Express.
La estructura debe permitir:
Importar casos de uso sin app.
Crear app con fakes.
Probar repositories con DB.
Reemplazar gateways.
Arrancar server solo en entry point.
Si un test necesita mockear 20 imports globales, las dependencias están ocultas.
Copiar Clean Architecture completa sin necesidad.
Carpetas globales por tipo.
utils como vertedero.
Domain que importa Express/Prisma.
Interface para cada clase sin motivo.
Singletons importados en cualquier lugar.
Módulos que se importan internamente entre sí sin API.
Organiza por responsabilidades y razones de cambio.
Feature-first mantiene contexto junto.
HTTP, application e infrastructure son boundaries útiles.
Composition root conecta implementaciones.
Empieza simple y separa cuando aparece complejidad.
La estructura debe mejorar testing y navegación.
¿Qué problema tiene organizar solo por controllers/services?
¿Qué es composition root?
¿Cuándo una interfaz aporta valor?
¿Por qué shared/utils es riesgoso?
¿Cómo evoluciona una feature pequeña?
Ver respuestas
Dispersa una feature por todo el proyecto.
Lugar donde se construyen y conectan dependencias.
Cuando define un boundary sustituible o testeable real.
Pierde ownership y acumula dependencias.
Comienza compacta y separa HTTP/application/infrastructure cuando cambian por razones distintas.
Dependency injection pragmática hace explícitas esas dependencias sin requerir un framework de contenedor.