Organized by Purpose: The Architecture Principle That Saved My Sanity

The folder structure nobody questions
Early in my career, every project I joined had the same layout: a controllers/ folder with twenty files, a models/ folder with twenty more, a services/ folder doubling that count. To add a single feature, you would touch files scattered across five directories. To understand how billing worked, you had to hold a mental map of the entire codebase.
This is grouping by technical role. It feels natural because frameworks teach it. But after ten years of shipping production software, I can say with confidence: it does not scale, and it actively slows teams down.
The alternative is simple. Group code by the problem it solves. Everything a feature needs lives together. I call this principle organized by purpose.
What it looks like in practice
In one of my projects -- a SaaS platform with a Node.js backend and Next.js frontend -- each business capability is a self-contained module. Here is the actual backend structure:
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/
Notice the pattern. The billing/ folder contains its routes, controller, API layer, domain types, validation schemas, the Stripe service adapter, tests, and mocks. If I need to change how subscriptions work, I open one folder. Everything I need is there.
The crm/ module owns its email templates, its WhatsApp service, and its onboarding message sequences. No hunting through a global templates/ folder wondering which module uses which template.
The registration point
Modules plug into the application through a single registration file:
// server/registerRoutes.ts
export const registerRoutes = (router: Router) => {
authRoutes(router);
clientRoutes(router);
challengeRoutes(router);
gameRoutes(router);
billingRoutes(router);
businessRoutes(router);
webhookRoutes(router);
crmRoutes(router);
// ...
};Each module exports its own routes. The server does not need to know how billing works internally -- it just connects the module to the router. This is a clean boundary. You could delete the entire billing folder and the only thing that breaks is a single import line in this file.
Frontend: co-location at the feature level
The same principle applies on the frontend. In the Next.js app, each game type has its own components, state, and logic co-located:
app/(influencer)/[slug]/
actions.tsx # Server actions for this feature
hooks/
useTrackGameVisit.ts # Hook specific to this context
(wheel-game)/
SingleWheelGameCC.tsx # Main component
store/
useWheelGameStore.ts # State management
components/
WheelContainer.tsx
WheelGameController.tsx
WheelPrizeDialog.tsx
GameSteps.tsx
BlockRendererGame.tsx
(scratch-game)/
SingleScratchGameCC.tsx
store/
useScratchGameStore.ts
components/
ScratchGameController.tsx
ScratchPrizeDialog.tsx
GameSteps.tsx
BlockRendererGame.tsx
The wheel game has its own store, its own controller component, its own prize dialog. The scratch game has equivalent but independent files. They do not share a global stores/ folder or a global components/ folder. Each game type is a self-contained feature you can reason about in isolation.
The domain types follow the same pattern -- each feature defines its own 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[];
// ...
}Billing types live in the billing domain. Game types live in the game domain. When a developer is working on billing, they never accidentally encounter game types in their editor.
Why this matters for the business
This is not academic. Organizing by purpose has three concrete effects on how a team ships software.
Faster feature delivery. When a product manager asks for a change to the onboarding email sequence, the developer opens crm/onboarding-messages/ and crm/emailService/templates/. Two directories, same module. No context-switching between distant parts of the codebase.
Fewer merge conflicts. If two developers work on billing and games simultaneously, they are working in completely different folders. Their branches will not conflict. In a role-based structure, both developers would likely be editing the same controllers/ and services/ directories.
Easier onboarding. A new developer joining the team can be productive on a single feature without understanding the entire application. "Your first task is in billing/" is a clear starting point. "Your first task involves controllers/billingController.ts, services/billingService.ts, models/subscription.ts, validators/billingValidator.ts, and routes/billingRoutes.ts" is not.
The shared layer
Not everything belongs to a single feature. Cross-cutting concerns like logging, database connections, authentication middleware, and shared domain types live in a shared/ directory:
src/shared/
logger/
mongodb/
cache/
queue/
domain/ # Types used across multiple features
marketing/
The rule is straightforward: if something is used by exactly one feature, it belongs in that feature's folder. If it is genuinely used by multiple features, it moves to shared/. Most things start in a feature folder and get promoted to shared only when a second consumer appears. Never preemptively.
The anti-pattern to avoid
The role-based structure looks like this, and you have probably seen it:
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
This groups files by what they are, not what they do. Every folder is a cross-section of the entire application. When someone asks "how does billing work?", you scan five directories. When you add a feature, you touch five directories. When two developers work on different features, they still edit the same directories.
It optimizes for the question "show me all the controllers" -- a question almost nobody asks in day-to-day development. What people actually ask is "show me everything related to billing."
A practical takeaway
If you are starting a new project or refactoring an existing one, apply this test to every file: can I answer "what problem does this solve?" from the folder path alone? If the path is billing/stripeService/StripeService.ts, the answer is clear. If the path is services/StripeService.ts, you need to open the file to find out.
Organize by purpose. Keep changes local. Let the folder structure tell the story of what your application does, not how it is technically assembled.