Two developers join your project. One is asked to add a "cancel subscription" feature. Before writing a line, they must answer: where does this code go?
If the answer takes twenty minutes of archaeology, your organisation is costing you real money on every change — and it compounds, because each poorly-placed addition makes the next one harder to place.
Code organisation is not tidiness. It is about making the cost of change predictable.
The default that stops working
Almost every tutorial starts you here:
src/
controllers/ userController.js, orderController.js, paymentController.js
services/ userService.js, orderService.js, paymentService.js
models/ user.js, order.js, payment.js
utils/Grouped by technical type. It is intuitive at first and fails at scale for a specific reason: a feature is spread across every folder.
Changing how payments work means editing controllers/paymentController.js, services/paymentService.js, models/payment.js, and probably three utilities. Four folders open, and you must already know the codebase to be confident you found everything.
Worse, nothing stops userService importing from paymentService. Over a year, everything imports everything, and the structure describes nothing about how the system actually works.
Grouping by feature
Put everything a feature needs in one place:
src/
features/
payments/
paymentController.js
paymentService.js
paymentRepository.js
payment.types.js
payment.test.js
orders/
...
users/
...
shared/
database.js
logger.jsThree concrete benefits:
Changes are local. Everything about payments lives under payments/. Delete the folder and the feature is gone — a useful property, because it means the feature really was self-contained.
The structure documents the product. A new developer opening src/features/ sees what the system does, not what frameworks it uses.
Boundaries become enforceable. You can add a lint rule forbidding one feature from importing another feature's internals, and now the architecture is checked rather than merely intended.
Coupling and cohesion
Two words that explain why one structure feels better than another.
Cohesion is how strongly the things inside a module belong together. High cohesion means a module has one clear job. A payments/ folder is cohesive. A utils/ folder containing date formatting, an email validator and a PDF generator is not — those three things share nothing except that nobody knew where to put them.
Coupling is how much modules depend on each other. Low coupling means you can change one without touching others.
The goal is high cohesion, low coupling. Grouping by feature naturally pushes both in the right direction: related things end up together (cohesion up) and unrelated things end up apart (coupling down).
Dependency direction
The single most important structural rule: dependencies must point in one direction, and must never form a cycle.
When A imports B and B imports A, you can no longer understand, test or reuse either alone. Some bundlers will even produce undefined at runtime depending on which module loads first — a genuinely baffling class of bug.
A layered arrangement makes the rule concrete:
Diagram, as text
- Features leads to Domain / business rules
- Features leads to Shared infrastructure
- Domain / business rules leads to Shared infrastructure
Everything may depend on shared infrastructure. Features may depend on the domain. Nothing may point back up. Shared code must never import a feature — the moment logger.js imports paymentService, your shared layer is no longer shared.
When two features genuinely need to communicate, choose one of these rather than importing each other:
- Move the common concept down into the domain layer where both can use it.
- Have one feature emit an event the other subscribes to.
- Introduce a coordinator above both that calls each in turn.
Module boundaries: the public front door
A feature folder should expose a deliberate interface, not everything it contains:
// features/payments/index.js — the only entry point
export { processPayment, refundPayment } from './paymentService.js';
export { PaymentError } from './errors.js';
// paymentRepository stays privateOther features import from features/payments, never from features/payments/paymentRepository.js. That distinction is what lets you restructure the inside of a feature without breaking anyone.
This is the same idea as encapsulation in a class, applied one level up.
Naming that carries its weight
Names are the interface to your structure.
Say what it is, not what it technically is. UserAuthenticator beats AuthHelper. A file named manager, handler, helper or data is usually one that never had its responsibility decided.
Be consistent about plurals and casing. Mixing feature/ and features/, or user_service.js and orderService.js, forces everyone to remember arbitrary details.
Let the path do work. features/payments/service.js reads better than features/payments/paymentService.js — the folder already said "payment", so repeating it is noise.
When a folder becomes a package
Feature folders scale a long way. Two signals suggest promoting one into a genuinely separate package or service:
- A different team owns it, and coordinating deploys is causing friction.
- It has genuinely different scaling or runtime needs — a CPU-heavy image processor next to a light API.
Both of those are organisational and operational reasons. "The folder is getting big" is not one, and splitting prematurely converts easy function calls into network calls you must now handle failing.
Check your understanding
Common mistakes
Reorganising without a reason. Large restructures create enormous diffs, break everyone's in-flight branches, and destroy git blame. Reorganise when the current structure is causing measurable pain.
Folders one level deep everywhere. Six nested folders to reach one file is as unhelpful as none. Two or three levels suits most projects.
Mirroring the database in your folders. Your code structure should follow the product's capabilities, not your tables.
Shared code that knows about features. The clearest architectural violation there is, and the easiest to catch in review.
One enormous file because "it is all related". Cohesion is about responsibility, not proximity. A 3,000-line file has several responsibilities whether or not you have named them.
Practice
A monolith organised by layers has become hard to change. Describe how you would reorganise it by feature, and what you would do about code genuinely shared between features.
Write your own answer first, on paper or in a note. Then compare.
Show a hint
Move things in an order where the codebase is working at every step.
Show a strong answer
Reorganise incrementally, keeping it working throughout — a big-bang reshuffle produces an unreviewable diff and a week of merge conflicts.
- Pick one feature with clear edges — the one with fewest inbound references. Create a folder for it and move its controller, service, model and tests into it. Nothing else changes; imports update but behaviour does not.
- Give it a front door. Everything outside the folder must enter through one module file rather than reaching into its internals. This is the step that makes the boundary real; without it you have moved files and changed nothing.
- Repeat, in order of independence. Each move is small and reviewable, and the codebase is shippable after every one.
- What is left over is the interesting part. Whatever cannot be assigned to a feature is either genuinely shared infrastructure or a hidden coupling worth examining.
Code genuinely shared between features — three categories, treated differently:
- Technical utilities with no domain knowledge — date formatting, retry helpers, an HTTP client wrapper. A
shared/orplatform/module is correct. These are safe to share because they depend on nothing above them. - A domain concept two features both need — a
Moneytype, anAddressvalue. Also fine to share, provided it stays a value with no dependencies on either feature. - Business logic two features both call. This is the dangerous one, and it usually means the boundary is wrong. If billing and reporting both need "calculate tax", tax is probably its own feature rather than a shared function. Sharing business logic between features is how a monolith becomes a distributed monolith later — the coupling survives the split and reappears as a network call.
The rule I would enforce with tooling: features may depend on shared/, and never on each other's internals. A lint rule on import paths keeps this true after everyone has forgotten the plan.
Two features both need 'send an email'. One team argues for a shared EmailService, another for each feature owning its own. Make both cases and decide.
Write your own answer first, on paper or in a note. Then compare.
Show a hint
Ask what varies between the two uses, and what would happen if one needed a change.
Show a strong answer
The case for a shared service: sending email involves a provider client, retries, bounce handling, suppression lists and rate limits. Implementing that twice is duplicated effort and duplicated bugs, and a change to the provider would need making in two places. Deliverability and compliance concerns — unsubscribe handling, sending domains — should be uniform.
The case for each feature owning its own: the two uses are not the same. A billing receipt is transactional, must be sent reliably, and must never be suppressed by a marketing preference. A weekly digest is bulk, must respect unsubscribe, and can be dropped under load. A single service serving both accumulates parameters and conditionals until it encodes both features' policies — and the two teams then contend over one component.
The decision: share the mechanism, not the policy.
- Shared: the transport — provider client, retries, connection handling, bounce processing. This is technical infrastructure with no domain knowledge, and it belongs in
shared/or as its own platform module. - Owned by each feature: what to send, when, to whom, whether preferences apply, and what happens if it fails. Billing decides that a receipt is transactional; the digest feature decides it is suppressible.
The test that generalises: ask what would change the code. If a change to the email provider affects both, that part is shared. If a change to when a receipt is sent would touch the shared service, the boundary is in the wrong place — policy has leaked into infrastructure, and that is the failure mode both teams should be arguing about rather than the ownership question.
Key takeaways
- Organisation exists to make the cost of change predictable, not to look tidy.
- Grouping by technical type spreads every feature across every folder. Grouping by feature keeps changes local and makes the structure describe the product.
- Aim for high cohesion and low coupling — feature grouping pushes both the right way.
utils/has no cohesion and couples everything. Name the real concept instead.- Dependencies must flow one way and never cycle. Shared code must never import a feature.
- Expose a deliberate public entry point per feature so its internals stay free to change.
- Split a folder into a service for team ownership or runtime needs — not because it grew.
Further reading
- Eric Evans, Domain-Driven Design (Addison-Wesley, 2003) — bounded contexts, and shared kernels.
- Robert C. Martin, Clean Architecture (Prentice Hall, 2017) — dependency direction and stable abstractions.
- Simon Brown, "Modular Monoliths" (GOTO 2018) — feature modules with enforced boundaries.
- Melvin Conway, "How Do Committees Invent?" (Datamation, 1968).
- Kent Beck, "Coupling and Cohesion" in Tidy First? (O'Reilly, 2023) — what changes together belongs together.