Guide

How we structure a Node.js + TypeScript backend that still makes sense two years later.

AuthorDnyaneshwar FulariProject Manager
CategoryNode.js & TypeScript
Reading time4 min read
PublishedMay 8, 2026
Topics
Node.js architecture TypeScript backend Express architecture backend best practices API design
Illustration: a cutaway of a machine, with tidy compartments of pipework on one side and conveyor lanes carrying red parcels on the other Dnyaneshwar Fulari, Project Manager at Script Lanes

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.

Diagram: four domain folders, each holding its own sub-folders, all connected down to a shared set of infrastructure folders
Folders are useful when they reflect business boundaries.

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?

Diagram: a request travelling from the internet through a validation funnel, a thin controller, a use case, and on to the database and external services
Keep HTTP thin. Keep decisions testable.

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.

Diagram: the same webhook arriving twice, an idempotency gate letting one through and deflecting the duplicate
Retries happen. Double-charging should not.

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