Organizado por propósito: el principio de arquitectura que cambió mi forma de trabajar

La estructura de carpetas que nadie cuestiona
En los primeros proyectos donde trabajé, todos tenían la misma estructura: una carpeta controllers/ con veinte archivos, una carpeta models/ con otros veinte, una carpeta services/ que duplicaba la cuenta. Para agregar una funcionalidad, tocabas archivos repartidos en cinco directorios. Para entender cómo funcionaba la facturación, tenías que mantener un mapa mental del proyecto entero.
Esto es agrupar por rol técnico. Se siente natural porque los frameworks lo enseñan así. Pero después de más de diez años entregando software en producción, puedo decir con confianza: no escala, y activamente frena a los equipos.
La alternativa es directa. Agrupa el código por el problema que resuelve. Todo lo que una funcionalidad necesita vive junto. Yo llamo a este principio organizado por propósito.
Cómo se ve en la práctica
En uno de mis proyectos -- una plataforma SaaS con backend en Node.js y frontend en Next.js -- cada capacidad de negocio es un módulo autocontenido. Esta es la estructura real del backend:
src/app/
billing/
BillingApi.ts
controller.ts
routes.ts
domain/
index.ts
planDisplayNames.ts
schemas/
subscriptionInfo.schema.ts
stripeService/
StripeService.ts
interfaces.ts
__tests__/
BillingApi.test.ts
StripeService.test.ts
__mocks__/
StripeService.mock.ts
test-fixtures.ts
crm/
CrmApi.ts
CrmEmailRepository.ts
domain/
emailService/
SengridEmailService.ts
templates/
welcome.template.ts
trialExpiredNoPay.template.ts
paymentFailed.template.ts
...
whatsappService/
WhatsappService.ts
onboarding-messages/
hito-1-completar-perfil/
hito-2-personalizar-juego/
...
games/
GameApi.ts
controller.ts
routes.ts
domain/
repository.ts
schemas/
challenges/
ChallengeApi.ts
controller.ts
routes.ts
domain/
repository.ts
schemas/
Observa el patrón. La carpeta billing/ contiene sus rutas, controlador, capa de API, tipos de dominio, schemas de validación, el adaptador del servicio de Stripe, tests y mocks. Si necesito cambiar cómo funcionan las suscripciones, abro una carpeta. Todo lo que necesito está ahí.
El módulo crm/ es dueño de sus plantillas de email, su servicio de WhatsApp, y sus secuencias de mensajes de onboarding. No hay que buscar en una carpeta global templates/ preguntándose qué módulo usa cada plantilla.
El punto de registro
Los módulos se conectan a la aplicación a través de un único archivo de registro:
// server/registerRoutes.ts
export const registerRoutes = (router: Router) => {
authRoutes(router);
clientRoutes(router);
challengeRoutes(router);
gameRoutes(router);
billingRoutes(router);
businessRoutes(router);
webhookRoutes(router);
crmRoutes(router);
// ...
};Cada módulo exporta sus propias rutas. El servidor no necesita saber cómo funciona billing internamente -- solo conecta el módulo al router. Esta es una frontera limpia. Podrías borrar toda la carpeta de billing y lo único que se rompe es una línea de import en este archivo.
Frontend: co-localización a nivel de feature
El mismo principio aplica en el frontend. En la app de Next.js, cada tipo de juego tiene sus componentes, estado y lógica co-locados:
app/(influencer)/[slug]/
actions.tsx # Server actions para esta feature
hooks/
useTrackGameVisit.ts # Hook específico de este contexto
(wheel-game)/
SingleWheelGameCC.tsx # Componente principal
store/
useWheelGameStore.ts # Gestión de estado
components/
WheelContainer.tsx
WheelGameController.tsx
WheelPrizeDialog.tsx
GameSteps.tsx
(scratch-game)/
SingleScratchGameCC.tsx
store/
useScratchGameStore.ts
components/
ScratchGameController.tsx
ScratchPrizeDialog.tsx
GameSteps.tsx
El juego de ruleta tiene su propio store, su propio componente controlador, su propio diálogo de premio. El juego de rascado tiene archivos equivalentes pero independientes. No comparten un stores/ global ni un components/ global. Cada tipo de juego es una feature autocontenida sobre la que puedes razonar de forma aislada.
Los tipos de dominio siguen el mismo patrón -- cada feature define sus propias interfaces:
// domain/billing/index.ts
export interface ISubscriptionInfo {
plan: {
name: string;
displayName: string;
status: "active" | "inactive" | "expired" | "trial";
endDate: string;
daysRemaining: number | null;
};
billing: {
hasPaymentMethod: boolean;
stripeCustomerId: string | null;
};
}
// domain/game/index.ts
export interface IGameContent {
_id: string;
businessId: string;
gameType: GameType;
prizes: IPrize[];
config: IGameConfig;
blocks: IBlock[];
// ...
}Los tipos de billing viven en el dominio de billing. Los tipos de game viven en el dominio de game. Cuando un desarrollador trabaja en billing, nunca se encuentra accidentalmente con tipos de game en su editor.
Por qué esto importa para el negocio
Esto no es académico. Organizar por propósito tiene tres efectos concretos en cómo un equipo entrega software.
Entrega de features más rápida. Cuando product pide un cambio en la secuencia de emails de onboarding, el desarrollador abre crm/onboarding-messages/ y crm/emailService/templates/. Dos directorios, mismo módulo. Sin cambio de contexto entre partes distantes del codebase.
Menos conflictos de merge. Si dos desarrolladores trabajan en billing y games simultáneamente, están trabajando en carpetas completamente diferentes. Sus ramas no van a tener conflictos. En una estructura basada en roles, ambos desarrolladores probablemente estarían editando los mismos directorios controllers/ y services/.
Onboarding más fácil. Un nuevo desarrollador que se incorpora al equipo puede ser productivo en una feature sin entender toda la aplicación. "Tu primera tarea está en billing/" es un punto de partida claro. "Tu primera tarea involucra controllers/billingController.ts, services/billingService.ts, models/subscription.ts, validators/billingValidator.ts, y routes/billingRoutes.ts" no lo es.
La capa compartida
No todo pertenece a una sola feature. Las responsabilidades transversales como logging, conexiones a base de datos, middleware de autenticación y tipos de dominio compartidos viven en un directorio shared/:
src/shared/
logger/
mongodb/
cache/
queue/
domain/ # Tipos usados por múltiples features
marketing/
La regla es directa: si algo lo usa exactamente una feature, pertenece a la carpeta de esa feature. Si genuinamente lo usan múltiples features, se mueve a shared/. La mayoría de cosas empiezan en una carpeta de feature y solo se promueven a shared cuando aparece un segundo consumidor. Nunca de forma preventiva.
El anti-patrón a evitar
La estructura basada en roles se ve así, y seguramente la has visto:
src/
controllers/
billingController.ts
gameController.ts
clientController.ts
challengeController.ts
services/
billingService.ts
gameService.ts
clientService.ts
models/
subscription.ts
game.ts
client.ts
routes/
billingRoutes.ts
gameRoutes.ts
clientRoutes.ts
validators/
billingValidator.ts
gameValidator.ts
Esto agrupa archivos por lo que son, no por lo que hacen. Cada carpeta es un corte transversal de toda la aplicación. Cuando alguien pregunta "cómo funciona billing?", escaneas cinco directorios. Cuando agregas una feature, tocas cinco directorios. Cuando dos desarrolladores trabajan en features diferentes, siguen editando los mismos directorios.
Optimiza para la pregunta "muéstrame todos los controladores" -- una pregunta que casi nadie hace en el día a día. Lo que la gente realmente pregunta es "muéstrame todo lo relacionado con billing."
Un consejo práctico
Si estás empezando un proyecto nuevo o refactorizando uno existente, aplica este test a cada archivo: ¿puedes responder "qué problema resuelve esto?" solo con la ruta de la carpeta? Si la ruta es billing/stripeService/StripeService.ts, la respuesta es clara. Si la ruta es services/StripeService.ts, necesitas abrir el archivo para averiguarlo.
Organiza por propósito. Mantén los cambios locales. Deja que la estructura de carpetas cuente la historia de lo que tu aplicación hace, no de cómo está ensamblada técnicamente.