How we structure a Node.js + TypeScript backend that still makes sense two years later.
Starting a Node.js API is easy. Keeping it understandable after six developers, 180 endpoints, three payment providers and one function named finalProcessOrderV2 is the real engineering problem.
The folder structure is not the architecture
A new Node.js backend can look wonderfully clean. There are six folders, ten routes and a database connection. Then the product succeeds. Suddenly there are permissions, webhooks, background jobs, imports, exports, notifications, retries and one customer with an integration nobody else uses.
At that point, the question is not whether the project has a services folder. The question is whether a developer can change the billing rules without accidentally breaking account creation. Architecture is about boundaries, not decorative folder names.
Organize around business domains
We prefer to group code around business domains: users, orders, subscriptions, reports, appointments. Each module keeps its own routes, validation, service logic and tests. Shared infrastructure stays shared; business rules stay close to the business they belong to.
A domain-first structure makes ownership clearer and limits how far a change can spread. It also makes code review easier, because the reviewer can see which part of the business is moving.
Keep HTTP thin
Controllers should translate HTTP into an application request and translate the result back. They should not become 200-line scripts that validate a payload, query four tables, charge a card, send an email and decide what ‘active customer’ means.
Put business decisions in services or use-case functions, where they can be tested without pretending an HTTP request exists. A useful test is simple. If we swapped Express for something else tomorrow, how much business logic would we have to move?
TypeScript is most valuable at boundaries
TypeScript shines when data moves between systems: request payloads, database records, queues, third-party APIs and frontend contracts. But a TypeScript type disappears once the code is running. It cannot check data arriving from the internet, so you still need real validation.
So we keep two sets of types. DTOs — plain shapes that describe exactly what an outside system sends us — and domain types that carry internal meaning, with deliberate mapping between the two. That little bit of friction catches a surprising number of ‘but the field is always there’ assumptions before production does.
Architecture is about limiting the blast radius of change.
Errors need a vocabulary
If every failure becomes a generic 500 error, running the system turns into archaeology. Define errors the product understands:
An error vocabulary
- validation error
- conflict
- forbidden action
- dependency unavailable
- rate limit
- payment declined
- retryable job failure
The goal is not a gigantic class hierarchy. The goal is consistent behaviour: what gets logged, what is safe to show the user, what should retry, and what requires human attention.
Design background work as a first-class system
Emails, imports, reports, AI jobs, image processing and webhook retries should not run inside a web request. When the request ends, that work should not die with it. Move it to a queue or a job system, and make each job idempotent: running it twice has the same effect as running it once.
Retries are not exceptional. Anything that talks to another system over a network will eventually repeat itself. If calling the same handler twice creates two invoices, you do not have a retry problem; you have an idempotency problem.
Observability before heroics
Observability is a plain idea. A production backend should be able to tell you what happened, without waking the original developer up to remember it. Structured logs, request IDs, job IDs, timings for external calls and useful error context all matter more than an elaborate architecture diagram nobody updates.
The best incident is the one the on-call engineer can understand in five minutes. The second best is the one that never happens because a metric warned you first.
A boring backend is a compliment
Two years into a product, the ideal backend is not clever. It is predictable. New engineers know where logic belongs. Tests describe important rules. Integrations are isolated. Deployments are routine.
Boring software is underrated. Boring software lets the product be exciting.
Found this useful? Build with us.
Tell us what you have in mind. Within 48 hours you'll hear back with an honest plan, clear pricing, and friendly, straight answers.
Start a projectStart a project