How to Structure a Production Node.js Backend

2026-08-1712 min read

How to Structure a Production Node.js Backend
Node.jsTypeScriptClean ArchitectureBackend EngineeringExpress

Most Express starters answer "how do I add a route" and stop there. Six months into a real project, the actual questions are different: where does validation live versus business logic, how do you swap SendGrid for another email provider without touching five files, how do you test a use case without spinning up MongoDB, and how does a new engineer find "the code that sends the welcome email" without grepping the whole repo.

This article walks through how I structure a production Node.js + TypeScript backend to answer those questions before they become problems — feature modules with clear internal layers, ports and adapters for anything external, and a small dependency-injection bootstrap that ties it together. I maintain this as an open starter template — node-express-template on GitHub — so the examples below are pulled directly from a structure you can clone and look at in full.


The problem with a typical Express layout

The default Express tutorial structure groups by technical type:

src/
  routes/
  controllers/
  models/
  services/

This is fine for a small API. It breaks down for the same reason type-based frontend structures do: understanding "how does password reset work" means opening four unrelated folders, business logic ends up scattered across controllers and services with no clear line between them, and nothing stops a controller from reaching directly into a database model — which is how "just swap the database" becomes a multi-week rewrite instead of an adapter swap.

The fix is the same idea applied to a backend: organize by feature module, and inside each module, separate what the business logic needs from how it's actually fulfilled.


The top-level shape

src/
1src/ 2app/ # Wiring: server bootstrap, DI container, env config, routing 3contracts/ # Ports — interfaces the application layer depends on 4infrastructure/ # Adapters — concrete implementations of those ports 5modules/ # Feature modules, each internally layered 6shared/ # Cross-cutting: errors, HTTP helpers, middleware, types 7i18n/ # Translation resources 8index.ts

Two folders do the most structural work here: contracts/ and infrastructure/. Everything else follows from the boundary they draw.


Ports and adapters: contracts vs infrastructure

The core idea, borrowed from hexagonal architecture: business logic should depend on an interface describing what it needs, never on a specific third-party library or service directly.

contracts/email/email.port.ts
1export interface EmailPayload { 2to: string; 3subject: string; 4html: string; 5} 6 7export interface EmailPort { 8send(payload: EmailPayload): Promise<void>; 9}

The actual implementation — SendGrid, in this case — lives entirely in infrastructure/, and knows nothing about the rest of the app beyond satisfying that interface:

infrastructure/email/sendgrid/sendgrid.adapter.ts
1import sgMail from "@sendgrid/mail"; 2import { EmailPayload, EmailPort } from "@/contracts/email/email.port"; 3import { env } from "@/app/config/env"; 4 5export class SendgridAdapter implements EmailPort { 6constructor() { 7 sgMail.setApiKey(env.SENDGRID_API_KEY); 8} 9 10async send(payload: EmailPayload): Promise<void> { 11 await sgMail.send({ 12 to: payload.to, 13 from: env.EMAIL_FROM, 14 subject: payload.subject, 15 html: payload.html, 16 }); 17} 18}

The business logic that sends an email never imports @sendgrid/mail directly — it depends on EmailPort. Swapping SendGrid for SES or Resend later means writing one new adapter file and changing one line in the dependency-injection container. Nothing in modules/ changes. This is the concrete payoff of the pattern, not an abstract architecture preference: the blast radius of a third-party swap becomes one file instead of "search the whole codebase for sgMail."

The same pattern applies to logging/, storage/, and anything else that talks to something outside the process — a LoggerPort behind Winston, a StoragePort behind an S3 upload provider. The rule for what belongs in contracts/ is simple: if a module needs it but doesn't need to know how it's fulfilled, it's a port.


Anatomy of a feature module

Each entry under modules/ follows the same internal shape, regardless of what it does:

modules/example/
1example/ 2application/ 3 dto/ 4 example.dto.ts # Input/output shapes for use cases 5 use-cases/ 6 send-email.use-case.ts 7 run-metrics.use-case.ts 8infrastructure/ 9 factory/ # Wires this module's dependencies together 10presentation/ 11 controllers/ 12 example.controller.ts # Translates HTTP <-> use case calls 13 routes/ 14 example.routes.ts 15 schemas/ 16 example.schema.ts # Request validation (Zod) 17index.ts # Public surface of the module

application/use-cases is where business logic actually lives — one file per operation, doing one thing, with dependencies passed in rather than imported directly:

modules/example/application/use-cases/send-email.use-case.ts
1import { EmailPort } from "@/contracts/email/email.port"; 2import { SendEmailDto } from "../dto/example.dto"; 3 4export class SendEmailUseCase { 5constructor(private readonly emailPort: EmailPort) {} 6 7async execute(dto: SendEmailDto): Promise<void> { 8 await this.emailPort.send({ 9 to: dto.to, 10 subject: dto.subject, 11 html: `<p>${dto.message}</p>`, 12 }); 13} 14}

Because SendEmailUseCase depends on EmailPort, not SendgridAdapter, it can be unit tested with a fake implementation of EmailPort — no network call, no test SendGrid account, no flakiness.

presentation/controllers stays thin — it's a translation layer between HTTP and the use case, nothing more:

modules/example/presentation/controllers/example.controller.ts
1import { Request, Response } from "express"; 2import { asyncHandler } from "@/shared/http/async-handler"; 3import { HttpResponse } from "@/shared/http/http-response"; 4import { SendEmailUseCase } from "../../application/use-cases/send-email.use-case"; 5 6export class ExampleController { 7constructor(private readonly sendEmailUseCase: SendEmailUseCase) {} 8 9sendEmail = asyncHandler(async (req: Request, res: Response) => { 10 await this.sendEmailUseCase.execute(req.body); 11 return HttpResponse.success(res, { message: "Email sent" }); 12}); 13}

If a controller starts accumulating if statements that decide what should happen rather than just how to respond, that logic belongs in the use case, not the controller. The controller's job ends at calling the use case and shaping the HTTP response.

infrastructure/factory inside each module is where that module's specific dependencies get wired together — instantiating the controller with its use case, the use case with its port implementation — keeping that wiring colocated with the module it serves rather than centralized in one giant file that every module modification has to touch.


Tying it together with a DI container

The wiring happens once, at startup, in app/bootstrap:

app/bootstrap/container.ts
1import { SendgridAdapter } from "@/infrastructure/email/sendgrid/sendgrid.adapter"; 2import { SendEmailUseCase } from "@/modules/example/application/use-cases/send-email.use-case"; 3import { ExampleController } from "@/modules/example/presentation/controllers/example.controller"; 4 5export function buildContainer() { 6const emailPort = new SendgridAdapter(); 7 8const sendEmailUseCase = new SendEmailUseCase(emailPort); 9const exampleController = new ExampleController(sendEmailUseCase); 10 11return { exampleController }; 12}

This is intentionally a plain function, not a DI framework with decorators and reflection metadata. For most Node APIs, explicit manual wiring is easier to read and debug than a framework doing it implicitly — you can trace exactly what gets instantiated with what, in what order, by reading one file top to bottom.


Shared: cross-cutting concerns, not a dumping ground

shared/ holds things genuinely used across every module — not a catch-all for anything that didn't fit elsewhere.

shared/http/http-response.ts
1import { Response } from "express"; 2 3export class HttpResponse { 4static success(res: Response, data: unknown, status = 200) { 5 return res.status(status).json({ success: true, data }); 6} 7 8static error(res: Response, message: string, status = 400) { 9 return res.status(status).json({ success: false, message }); 10} 11}
shared/errors/app.error.ts
1export class AppError extends Error { 2constructor( 3 public readonly message: string, 4 public readonly statusCode: number, 5 public readonly code: string, 6) { 7 super(message); 8 Object.setPrototypeOf(this, AppError.prototype); 9} 10}

Every module throws the same AppError, and one global handler in shared/http/middlewares/global.error.handler.ts is the only place that decides how an error becomes an HTTP response — logging it, shaping it consistently, and never leaking a stack trace to a client in production. A use case doesn't know or care whether it's being called from an HTTP controller, a queue consumer, or a test — it just throws AppError and lets whatever's calling it decide what to do with that.

async-handler.ts is a small but important piece — wrapping every controller method so a rejected promise reaches Express's error handling instead of silently becoming an unhandled rejection, which is one of the more common ways an Express API produces a hung request instead of a clean error response.


Infrastructure that doesn't fit a port

Not everything under infrastructure/ is a port implementation — database connection setup, repository base classes, and framework-level plugins live here too, because they're still "how we talk to something external," even without a matching contracts/ interface.

infrastructure/database/repositories/base.repository.ts
1import { Model, FilterQuery } from "mongoose"; 2import { buildPagination } from "../helpers/build.pagination"; 3 4export abstract class BaseRepository<T> { 5constructor(protected readonly model: Model<T>) {} 6 7async findMany(filter: FilterQuery<T>, page: number, limit: number) { 8 const { skip, take } = buildPagination(page, limit); 9 const [items, total] = await Promise.all([ 10 this.model.find(filter).skip(skip).limit(take), 11 this.model.countDocuments(filter), 12 ]); 13 return { items, total }; 14} 15}

A shared BaseRepository earns its place because pagination and basic query building are genuinely identical across every collection — this is the one place duplicating logic per-module would be pure cost with no benefit, unlike a shared component or schema, where premature sharing usually hurts more than it helps.


Trade-offs

Layered modules vs a flatter MVC structure

This structure has real upfront cost — more files, more indirection, a steeper first-week learning curve for a new contributor. It pays for itself once the app has enough modules that "where does this logic live" stops being obvious by default. For a small API with two or three endpoints, a flatter routes/controllers/services layout is genuinely simpler and the extra structure isn't worth it yet.

Manual DI vs a DI framework

A framework like InversifyJS or tsyringe automates wiring and can reduce boilerplate as the dependency graph grows large. Manual wiring in one container.ts file stays simpler to trace and debug, and avoids the decorator/reflection metadata overhead — the trade-off shows up mainly at significant scale, where a manual container file can get long.

Ports for everything vs only where it's proven necessary

Wrapping every single dependency behind an interface, including ones you'll realistically never swap, adds indirection without payoff. I reserve contracts/ for things with a real chance of changing — email providers, storage backends, external APIs — not for, say, a utility library you're confident you'll never replace.


Common mistakes

  1. Business logic living in controllers instead of use cases, making it untestable without spinning up an HTTP server.
  2. Modules importing directly from another module's internals instead of through its index.ts public surface, recreating the tangled coupling feature-based structure is meant to avoid.
  3. Adapters that leak implementation details through the port interface — an EmailPort.send() that accepts a SendGrid-specific options object isn't actually abstracting the provider.
  4. A shared/ folder that becomes a dumping ground for anything that didn't obviously belong somewhere else, rather than genuinely cross-cutting concerns.
  5. Skipping the async handler wrapper on a controller method, letting a rejected promise become an unhandled rejection instead of a clean error response.
  6. Over-engineering a small API with the full pattern before there's enough surface area to justify it — this structure is a response to real growing pains, not a default for every project regardless of size.

Practical recommendations

  • Separate what business logic needs (contracts/) from how it's fulfilled (infrastructure/) for anything talking to an external service.
  • Keep controllers thin — translation between HTTP and a use case, nothing that makes a business decision.
  • One use case per operation, depending on ports, not concrete implementations, so it stays testable in isolation.
  • Wire dependencies explicitly in one bootstrap file rather than reaching for a DI framework until the manual version genuinely gets unwieldy.
  • Reserve shared/ for things every module actually needs — errors, HTTP helpers, middleware — not a catch-all.
  • Scale the structure to the project. A three-endpoint internal tool doesn't need this; a production API with a growing team does.

Try the starter

I maintain this structure as an open starter template with the module pattern, DI container, error handling, i18n, and observability wiring already in place:

node-express-template

It's meant to be cloned and adapted, not treated as gospel — the module boundaries and port/adapter split are the parts worth keeping regardless of what you swap out underneath.


Conclusion

A production Node.js backend doesn't need an exotic architecture — it needs a small number of consistent boundaries that hold as the codebase grows: business logic that doesn't know which third-party library fulfills its dependencies, controllers that stay thin, and modules that are self-contained enough to understand, test, and delete independently. Ports and adapters, feature modules, and explicit dependency wiring are three ways of enforcing the same underlying discipline — the framework and library choices underneath are the least important part of getting this right.

Related articles