Skip to content
4 min read

Structuring an Express API that outlives sprint one

Express gives you a router and no opinions. The folder layout, boundaries and conventions that keep a Node API readable once it is twenty routes and three developers.

On this page
  1. Split by feature, not by layer
  2. Separate the app from the server
  3. Validate at the boundary, infer the type from it
  4. Keep handlers boring
  5. One config module, validated at startup
  6. One database client
  7. None of it is clever

Express ships a router and declines to have opinions about anything else. That is why it is still everywhere, and why so many Express codebases arrive at the same destination: a 900-line routes.js, business logic inside handlers, and a utils.js that has become the place things go when nobody knows where they belong.

None of that is Express's fault. It is what happens when the first three files become the architecture by default.

Split by feature, not by layer

The instinct from tutorials is layers.

code
src/
  controllers/   leads.js  users.js  reports.js
  services/      leads.js  users.js  reports.js
  models/        leads.js  users.js  reports.js

It looks organised and works badly, because no change is ever confined to a layer. Adding a field to a lead means three files in three folders, and understanding how leads work means holding three directories open at once.

Group by the thing instead.

code
src/
  leads/
    routes.ts        # HTTP surface
    service.ts       # what a lead actually does
    queries.ts       # how it is stored
    schema.ts        # what a valid request looks like
  users/
    …
  shared/
    config.ts        # environment, validated once
    db.ts            # one client, exported
    errors.ts        # the error types the app throws
  app.ts             # builds the app, binds nothing
  server.ts          # starts it

One feature, one folder. A new developer reads leads/ and knows how leads work. Deleting the feature is deleting a directory, which is a property you only appreciate the first time you get to use it.

Separate the app from the server

Two files, and the split pays for itself the first time anyone writes a test.

src/app.tsts
import express from "express";
import { leadRoutes } from "./leads/routes";
import { errorHandler } from "./shared/errors";

export function createApp() {
  const app = express();
  app.use(express.json({ limit: "1mb" }));
  app.use("/api/leads", leadRoutes);
  app.use(errorHandler);          // last, always
  return app;
}
src/server.tsts
import { createApp } from "./app";
import { config } from "./shared/config";

createApp().listen(config.port, () => {
  console.log(`listening on ${config.port}`);
});

app.ts constructs and server.ts starts. Tests import createApp() and drive it with supertest without binding a port, without a running database if the service is stubbed, and without a teardown that leaves a dangling handle keeping the test runner alive.

Validate at the boundary, infer the type from it

Express types req.body as any, which means every handler is one typo from a runtime error TypeScript was perfectly happy about.

Validate once, at the edge, and let the schema be the source of the type.

src/leads/schema.tsts
import { z } from "zod";

export const createLead = z.object({
  source:    z.enum(["web", "phone", "marketplace"]),
  sourceRef: z.string().min(1).max(64),
  name:      z.string().min(1).max(120),
  phone:     z.string().regex(/^[0-9+\-\s]{6,20}$/),
});

export type CreateLead = z.infer<typeof createLead>;

Now the runtime check and the compile-time type cannot drift apart, because one is derived from the other rather than maintained alongside it.

Keep handlers boring

A route handler has three jobs. Parse, delegate, respond.

src/leads/routes.tsts
import { Router } from "express";
import { createLead } from "./schema";
import * as service from "./service";

export const leadRoutes = Router();

leadRoutes.post("/", async (req, res, next) => {
  try {
    const input = createLead.parse(req.body);
    const lead = await service.capture(input);
    res.status(201).json({ id: lead.id });
  } catch (err) {
    next(err);                       // one place decides what an error looks like
  }
});

Everything interesting lives in service.capture(), which knows nothing about HTTP.

That matters more than it sounds. The next thing that needs to capture a lead is a queue consumer, a CSV importer or a webhook receiver, and none of them have a req. Logic stranded in a handler gets copied rather than called, and then you have two of them, drifting.

The webhook case brings its own requirement, since a capture path reachable from more than one source will receive the same lead twice. That is why idempotency belongs in the write path rather than in each caller separately.

One config module, validated at startup

src/shared/config.tsts
import { z } from "zod";

const schema = z.object({
  PORT: z.coerce.number().default(3000),
  DATABASE_URL: z.string().url(),
  REDIS_URL: z.string().url().optional(),
});

const parsed = schema.safeParse(process.env);
if (!parsed.success) {
  console.error("Bad environment:", parsed.error.flatten().fieldErrors);
  process.exit(1);                   // fail at boot, not during a request
}

export const config = { port: parsed.data.PORT, databaseUrl: parsed.data.DATABASE_URL };

A missing variable now kills the process at startup with a readable message, instead of surfacing as undefined inside a request three hours later when nobody is connecting the two events.

One database client

Export a single client from shared/db.ts and import it everywhere. Creating a pool per module is one of the quieter causes of connection exhaustion, and module-level state that grows per request is a memory leak waiting for traffic.

If the product is multi-tenant, this module is also where the tenant boundary gets enforced, for the reason covered in multi-tenant data isolation. The filter has to be structural rather than remembered, and a repository nobody can bypass is how you get that.

None of it is clever

Express will not stop you building anything, a mess very much included.

The structure that holds up is unremarkable: feature folders, an app separate from the server, validation at the boundary, handlers that delegate, and one module each for config and the database client.

That it is boring is the point. The codebase should be dull enough that the fifth developer can add a route without reading what the other four did.

Frequently asked

How should I structure an Express project?

Group by feature rather than by layer. A leads/ folder holding its routes, its service and its queries keeps one change in one place, where a controllers/ plus services/ plus models/ split spreads the same change across three folders. Keep app construction separate from server start so tests can import the app without binding a port.

Should business logic go in the Express route handler?

No. A handler should parse and validate the request, call one function that does the work, and turn its result into a response. Logic inside the handler can only be tested through HTTP and cannot be reused by a job, a script or a queue consumer, which is where that same logic is needed next.

Do I need TypeScript for an Express API?

Not required, and hard to regret. Express's own types are weak around req.body and req.params, so the real benefit comes from validating input at the boundary with a schema library and inferring the type from the schema — that way the type and the runtime check cannot drift apart.

How do I organise configuration in a Node API?

Read environment variables in exactly one module, validate them at startup, and export a typed object. Everything else imports that. Reading process.env across many files means a missing variable surfaces as undefined deep in a request instead of as a clear failure when the process starts.

Found this useful?Share on XShare on LinkedIn

Related reading