The folder structure is not the architecture. I learned that after seeing codebases with beautiful controllers, services, and repositories directories where one request still jumped through six empty wrapper functions before reaching a database query.
A useful structure should make a change predictable. If I need to add withdrawal approval, I should know where input validation, business rules, persistence, and asynchronous work belong. If the folders cannot answer that, adding more layers will not help.
start with the change you need to make
Do not choose a project structure from a diagram before the application has behavior.
Start with three questions:
- Where does untrusted input enter?
- Where are business decisions made?
- Where do irreversible side effects happen?
For a small Node.js API, those answers often map to a route, a service, and a repository or external client. That is enough structure to begin.
src/
config/
routes/
services/
repositories/
clients/
jobs/
lib/
app.ts
server.ts
Each folder needs a rule, not just a name.
| Area | Owns | Must not own |
|---|---|---|
routes |
HTTP parsing, authentication context, response mapping | Business workflows and raw SQL |
services |
Business rules and operation boundaries | Framework request/response objects |
repositories |
Queries and persistence mapping | Email, queues, or HTTP responses |
clients |
External provider calls | Product decisions |
jobs |
Queue input and retry orchestration | Duplicate business logic |
config |
Parsed and validated runtime settings | Scattered process.env reads |
one request through the layers
Suppose the API needs to request a withdrawal. The route should stay boring:
router.post('/withdrawals', requireUser, async (req, res) => {
const input = withdrawalSchema.parse(req.body);
const withdrawal = await requestWithdrawal({
userId: req.user.id,
asset: input.asset,
amount: input.amount,
destination: input.destination,
});
res.status(202).json({ withdrawal });
});
The route knows HTTP. It validates the request, provides authenticated identity, calls one application operation, and maps the result to a response.
The service owns the decision:
export async function requestWithdrawal(input: WithdrawalInput) {
return db.transaction(async (tx) => {
const account = await accountRepository.findForUpdate(tx, input.userId, input.asset);
if (!account || account.availableBalance < input.amount) {
throw new InsufficientBalanceError();
}
const withdrawal = await withdrawalRepository.create(tx, {
...input,
status: 'pending',
});
await accountRepository.reserve(tx, account.id, input.amount);
await outboxRepository.add(tx, {
type: 'withdrawal.requested',
aggregateId: withdrawal.id,
});
return withdrawal;
});
}
This is not a universal withdrawal design. The important point is ownership: the balance check, reservation, withdrawal record, and event intent share one transaction because together they form one business decision.
The repository does not decide whether the balance is sufficient. It exposes persistence operations the service can combine.
what CryptoEx changed about my preference
In a small CRUD project, separating every query behind a repository can feel ceremonial. In CryptoEx, money-moving workflows made the boundary more valuable because transaction behavior had to be visible.
The service needed to show which writes belonged together. Queue jobs and provider clients needed separate ownership because a database transaction cannot include an external transfer. That naturally led to a local transaction first, followed by an outbox or job that performed the external side effect with a stable idempotency key.
The lesson was not “every project needs an outbox.” It was that folders should reflect failure boundaries. A profile update and a withdrawal do not deserve identical architecture merely because both arrive through HTTP.
when responsibility folders stop working
The top-level structure above works while the application is small enough that services/ contains a manageable set of files. Later, it can turn into a junk drawer:
services/
auth-service.ts
invoice-service.ts
notification-service.ts
report-service.ts
user-service.ts
withdrawal-service.ts
...forty more files
That is when I move toward feature ownership without discarding the layer rules:
src/
modules/
withdrawals/
withdrawal.routes.ts
withdrawal.service.ts
withdrawal.repository.ts
withdrawal.schemas.ts
withdrawal.types.ts
accounts/
account.service.ts
account.repository.ts
platform/
database/
queue/
logging/
config/
app.ts
server.ts
Move when navigation has become costly, not because a style guide says feature folders are more advanced.
avoid a shared folder with no owner
utils.ts is usually where architecture goes to disappear. A function starts as a harmless formatter, then the file accumulates token parsing, retries, currency conversion, and database helpers.
Prefer names that reveal the contract:
lib/
money.ts
pagination.ts
retry-policy.ts
request-id.ts
If code belongs to one feature, keep it with that feature. Move it to shared infrastructure only after two real consumers need the same behavior and agree on the contract.
Premature sharing creates coupling that looks like reuse.
keep framework objects at the edge
A service that accepts an Express Request is difficult to reuse from a queue job and awkward to test.
Avoid this:
async function createInvoice(req: Request) {
const userId = req.user.id;
const amount = req.body.amount;
// business logic
}
Prefer explicit input:
async function createInvoice(input: {
userId: string;
amount: number;
}) {
// business logic
}
Now HTTP, a scheduled task, or a message consumer can call the same operation without pretending to be Express.
jobs should call operations, not copy them
A queue handler is another adapter. It parses queue input, calls a service, and reports success or failure.
worker.process('withdrawal.execute', async (job) => {
const input = withdrawalJobSchema.parse(job.data);
await executeWithdrawal(input);
});
If the HTTP route and job each implement status transitions separately, they will eventually disagree. Put the transition in one operation and make adapters thin.
The job still owns queue-specific concerns: retry policy, visibility timeout, dead-letter behavior, and correlation IDs. Those are transport concerns, not withdrawal rules.
test boundaries according to risk
The structure should make testing less theatrical.
- Test route validation and response mapping with a small number of integration tests.
- Test service decisions with real transaction behavior when correctness depends on constraints or locking.
- Test repository queries against the database engine you deploy.
- Test external clients with recorded contracts or provider sandboxes.
- Test jobs for retries and duplicate delivery, not just successful execution.
Mocking every layer can produce a green suite where the assembled system still fails. A repository abstraction is useful when it clarifies ownership; it is not a reason to replace every database interaction with a hand-written fake.
migrate without rewriting the application
If the current project has giant route handlers, do not pause feature work for a month-long folder rewrite.
Use one workflow:
- Pick the next route you must change.
- Extract its business decision into a service with explicit input.
- Move database operations only when the boundary becomes clearer.
- Add a test around the failure that motivated the change.
- Write a short rule for where the next similar change belongs.
After several real changes, a structure emerges from the application instead of being imposed on it.
the rule I use now
Start by responsibility because it is easy to understand. Move to feature modules when navigation and ownership become painful. Add infrastructure patterns when a real failure mode demands them.
The best Node.js structure is not the one with the most recognizable architecture names. It is the one where the next developer can predict where a decision lives, trace a side effect, and change one workflow without reading the entire repository.
Discussion
What would you try, change, or challenge after reading this guide? Specific results and errors help the next reader.
Comments will load as you reach this section.