Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

A Clean Project Structure for TypeScript and Node.js API Services

About Post

Laravel hands you a folder structure on day one. Controllers go here, models go there, config lives in config/, and every Laravel developer can find their way around any Laravel project.

Node.js hands you an empty folder and index.js. That freedom is lovely for a weekend script and expensive for an API that will live for years. Six months in, business rules are inside route handlers, process.env is read in twenty places, and the only way to test anything is to start the whole server.

My personal API services are written in TypeScript and Node.js, tested with Jest and run in Docker, and the structure below is the shape I'd recommend for that kind of service. The examples use Express and Zod because they're widely known; the same ideas work with Fastify or another validation library.

The layout

src/
  app.ts                 # builds the app, never calls listen()
  server.ts              # starts it, handles shutdown
  config.ts              # env vars, validated once
  modules/
    employees/
      employee.routes.ts
      employee.schema.ts
      employee.service.ts
      employee.repository.ts
      employee.service.test.ts
  shared/
    errors.ts
    error-handler.ts
    validate.ts

Two decisions are doing the work here.

Group by feature, not by type. A controllers/, services/, models/ layout means every change touches four folders. With modules/employees/, everything about employees sits together, and deleting a feature means deleting one folder.

Separate building the app from starting it. app.ts exports a function that returns a configured app. server.ts is the only file that calls listen(). That one split is what makes fast HTTP tests possible later.

Three layers, one rule

  • Routes know about HTTP: paths, status codes, req and res. They validate input and call a service. Nothing else.
  • Services hold the business rules. They take plain data and return plain data. They never see req or res.
  • Repositories talk to the database. Nothing else does.

The rule: dependencies point inward. Routes can use services, services can use repositories, never the other way round. Then a service can be called from an HTTP route, a queue consumer or a CLI script without changes.

Config: validate once, fail fast

Reading process.env.DATABASE_URL all over the code means a missing variable shows up as a strange error at 2am, deep inside a request. Validate the environment once, at startup, and export a typed object:

import { z } from 'zod';

const EnvSchema = z.object({
  NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
  PORT: z.coerce.number().int().default(3000),
  DATABASE_URL: z.string().min(1),
});

export const config = EnvSchema.parse(process.env);

If a variable is missing or malformed, the service refuses to start and tells you exactly which one. That's the best possible time to find out.

Validation at the edge, types for free

A Zod schema gives you runtime validation and a TypeScript type from one definition, so they can't drift apart:

export const CreateEmployeeSchema = z.object({
  name: z.string().min(1).max(255),
  email: z.string().email(),
  joinedAt: z.coerce.date(),
});

export type CreateEmployeeInput = z.infer<typeof CreateEmployeeSchema>;

Validate in the route layer, before anything else runs. By the time data reaches a service, it's already the right shape, and the service's parameter type says so.

Errors: typed in the core, translated at the edge

Services shouldn't decide HTTP status codes, but they do know what went wrong. Give them a small family of error classes:

export class AppError extends Error {
  constructor(public readonly status: number, public readonly code: string, message: string) {
    super(message);
  }
}

export class NotFoundError extends AppError {
  constructor(what: string) {
    super(404, 'NOT_FOUND', `${what} not found`);
  }
}

Then one error-handling middleware, registered last, turns them into consistent JSON. Known errors get their status and code; anything unexpected is logged in full and returned as a generic 500, so stack traces never leak to clients:

export function errorHandler(err: unknown, _req: Request, res: Response, _next: NextFunction) {
  if (err instanceof AppError) {
    return res.status(err.status).json({ error: { code: err.code, message: err.message } });
  }
  console.error(err); // use your real logger here
  res.status(500).json({ error: { code: 'INTERNAL', message: 'Something went wrong' } });
}

Express needs all four parameters to recognise an error handler. And since Express 5, a rejected promise in an async route handler is passed to this middleware automatically, so you no longer need to wrap every handler in try/catch.

Services that are easy to test

Pass the repository into the service instead of importing it. That's all "dependency injection" needs to mean here:

export class EmployeeService {
  constructor(private readonly employees: EmployeeRepository) {}

  async getById(id: string) {
    const employee = await this.employees.findById(id);
    if (!employee) throw new NotFoundError('Employee');
    return employee;
  }
}

Now a Jest unit test needs no database and no server:

it('throws NotFoundError for an unknown employee', async () => {
  const repo: EmployeeRepository = {
    findById: jest.fn().mockResolvedValue(null),
    create: jest.fn(),
  };

  const service = new EmployeeService(repo);

  await expect(service.getById('42')).rejects.toBeInstanceOf(NotFoundError);
});

For HTTP-level tests, the app.ts split pays off: pass the app from createApp() to supertest and it handles requests in memory, with no port and no running server. Those tests check what the unit tests can't: routing, validation responses and the error format.

The test of a good structure: can you test a business rule without starting a server or a database? If yes, the layers are doing their job. If no, HTTP or SQL has leaked into the middle.

Docker: small, reproducible, not root

A multi-stage build compiles TypeScript in one image and ships only the compiled output and production dependencies in the next:

FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]

Copying package*.json before the source keeps the dependency layer cached between builds. USER node means the process doesn't run as root. And starting with node directly, rather than through npm start, lets the process receive the stop signal from Docker, so server.ts can close connections gracefully on SIGTERM.

The checklist

  • Feature folders; app.ts separate from server.ts.
  • Routes for HTTP, services for rules, repositories for data.
  • Config validated once at startup.
  • Schemas at the edge, with types inferred from them.
  • Typed errors in the core, one error handler at the edge.
  • Dependencies passed in, so Jest tests stay fast.
  • Multi-stage Docker image, running as a non-root user.

How do you structure your Node APIs: feature folders or layer folders? And has a framework like NestJS made the question go away for you?

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close