References

Core Patterns

Express has no enforced structure. The patterns below apply screaming architecture principles to an Express codebase: features own their routes, controllers, and services; the framework lives at the edge; and the application shuts down gracefully.

Feature-Based Folder Structure

Each business feature owns all of its code. The folder name reflects the business concept, not a technical role.

src/
├── features/
│   ├── users/
│   │   ├── users.routes.ts       # Express Router — framework at the edge
│   │   ├── users.controller.ts   # HTTP handlers — thin, delegates to service
│   │   ├── users.service.ts      # Business logic — no Express imports
│   │   ├── users.repository.ts   # Data access — no Express imports
│   │   ├── users.dto.ts          # Zod schemas for request/response shapes
│   │   ├── users.errors.ts       # Domain errors specific to this feature
│   │   └── users.test.ts         # Unit tests co-located with the feature
│   ├── orders/
│   │   ├── orders.routes.ts
│   │   ├── orders.controller.ts
│   │   ├── orders.service.ts
│   │   ├── orders.repository.ts
│   │   ├── orders.dto.ts
│   │   └── orders.test.ts
│   └── payments/
│       ├── payments.routes.ts
│       ├── payments.service.ts
│       ├── payments.dto.ts
│       └── payments.test.ts
├── shared/
│   ├── database/
│   │   └── client.ts             # DB client singleton (Prisma, pg, etc.)
│   ├── middleware/
│   │   ├── auth.middleware.ts
│   │   ├── validate.middleware.ts
│   │   └── error.middleware.ts
│   ├── errors/
│   │   └── app.error.ts          # AppError base class with statusCode
│   └── logger/
│       └── index.ts
├── app.ts                        # Express app setup — middleware + router mounting
├── server.ts                     # HTTP server creation + graceful shutdown
└── config.ts                     # Env var loading and validation

Each layer has a strict rule about what it may import:

routes.ts      → imports: controller, middleware (Express allowed here)
controller.ts  → imports: service, dto (NO Express Request/Response in service)
service.ts     → imports: repository, domain errors (NO Express imports)
repository.ts  → imports: DB client (NO Express imports, NO service)

Router Mounting Strategy

The app.ts file mounts feature routers under versioned API paths. It does not contain route logic — only orchestration.

// src/app.ts
import express from 'express';
import helmet from 'helmet';
import { userRouter } from './features/users/users.routes';
import { orderRouter } from './features/orders/orders.routes';
import { paymentRouter } from './features/payments/payments.routes';
import { errorHandler } from './shared/middleware/error.middleware';
import { db } from './shared/database/client';

export function createApp() {
  const app = express();

  // Global middleware
  app.use(helmet());
  app.use(express.json({ limit: '1mb' }));

  // Health check — no auth, no versioning
  app.get('/health', (_req, res) => res.json({ status: 'ok' }));

  // Versioned API routes — pass dependencies explicitly
  app.use('/api/v1/users',    userRouter({ db }));
  app.use('/api/v1/orders',   orderRouter({ db }));
  app.use('/api/v1/payments', paymentRouter({ db }));

  // Error handler last
  app.use(errorHandler);

  return app;
}

Feature router — accepts injected dependencies, creates no global state:

// src/features/users/users.routes.ts
import { Router } from 'express';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { UsersRepository } from './users.repository';
import { validate } from '../../shared/middleware/validate.middleware';
import { authenticate } from '../../shared/middleware/auth.middleware';
import { CreateUserDto, UserIdParamDto } from './users.dto';

interface Deps { db: DatabaseClient }

export function userRouter({ db }: Deps): Router {
  const repository  = new UsersRepository(db);
  const service     = new UsersService(repository);
  const controller  = new UsersController(service);
  const router      = Router();

  router.get(
    '/',
    authenticate,
    controller.list
  );
  router.get(
    '/:id',
    authenticate,
    validate(UserIdParamDto, 'params'),
    controller.getById
  );
  router.post(
    '/',
    authenticate,
    validate(CreateUserDto, 'body'),
    controller.create
  );
  router.delete(
    '/:id',
    authenticate,
    validate(UserIdParamDto, 'params'),
    controller.remove
  );

  return router;
}

Graceful Shutdown Pattern

The server.ts file owns the HTTP server lifecycle. On SIGTERM, it stops accepting new connections, drains in-flight requests, and closes the DB connection before exiting.

// src/server.ts
import { createApp } from './app';
import { db } from './shared/database/client';
import { logger } from './shared/logger';

async function main() {
  const app  = createApp();
  const port = Number(process.env.PORT ?? 3000);

  const server = app.listen(port, () => {
    logger.info(`Server listening on port ${port}`);
  });

  // Track open connections to drain during shutdown
  const connections = new Set<import('net').Socket>();
  server.on('connection', (socket) => {
    connections.add(socket);
    socket.once('close', () => connections.delete(socket));
  });

  async function shutdown(signal: string) {
    logger.info(`${signal} received — starting graceful shutdown`);

    // 1. Stop accepting new connections
    server.close(async () => {
      logger.info('HTTP server closed');

      // 3. Close DB after all HTTP requests are done
      await db.disconnect();
      logger.info('Database disconnected');
      process.exit(0);
    });

    // 2. Destroy lingering keep-alive connections
    for (const socket of connections) {
      socket.destroy();
    }

    // 4. Force exit if drain takes too long
    setTimeout(() => {
      logger.error('Graceful shutdown timed out — forcing exit');
      process.exit(1);
    }, 10_000).unref();
  }

  process.on('SIGTERM', () => shutdown('SIGTERM'));
  process.on('SIGINT',  () => shutdown('SIGINT'));

  // Unhandled promise rejections crash the process — treat as fatal
  process.on('unhandledRejection', (reason) => {
    logger.error('Unhandled rejection', reason);
    process.exit(1);
  });
}

main();

Dependency Injection Without Frameworks

Express has no DI container. Constructor injection achieves testability and replaceability without any library.

// src/features/users/users.repository.ts
// Depends on an abstract interface, not a concrete DB client
export interface IUsersRepository {
  findById(id: string): Promise<User | null>;
  findAll(): Promise<User[]>;
  create(data: CreateUserData): Promise<User>;
  delete(id: string): Promise<void>;
}

export class UsersRepository implements IUsersRepository {
  constructor(private readonly db: DatabaseClient) {}

  async findById(id: string): Promise<User | null> {
    return this.db.query('SELECT * FROM users WHERE id = $1', [id]);
  }
  async findAll(): Promise<User[]> {
    return this.db.query('SELECT * FROM users');
  }
  async create(data: CreateUserData): Promise<User> {
    return this.db.query(
      'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *',
      [data.name, data.email]
    );
  }
  async delete(id: string): Promise<void> {
    await this.db.query('DELETE FROM users WHERE id = $1', [id]);
  }
}
// src/features/users/users.service.ts
// Depends on the interface, not the concrete repository
export class UsersService {
  constructor(private readonly users: IUsersRepository) {}

  async getById(id: string): Promise<User> {
    const user = await this.users.findById(id);
    if (!user) throw new NotFoundError(`User ${id} not found`);
    return user;
  }

  async create(data: CreateUserData): Promise<User> {
    const existing = await this.users.findByEmail(data.email);
    if (existing) throw new ConflictError('Email already registered');
    return this.users.create(data);
  }
}
// src/features/users/users.controller.ts
// Depends on the service, delegates HTTP concerns here
export class UsersController {
  constructor(private readonly users: UsersService) {}

  list: RequestHandler = async (_req, res, next) => {
    try {
      const users = await this.users.getAll();
      res.json(users);
    } catch (err) { next(err); }
  };

  create: RequestHandler = async (req, res, next) => {
    try {
      const user = await this.users.create(req.body);
      res.status(201).json(user);
    } catch (err) { next(err); }
  };
}

Testing is straightforward because every class receives its dependencies from outside:

// users.service.test.ts
const mockRepo: IUsersRepository = {
  findById: jest.fn(),
  findAll:  jest.fn(),
  create:   jest.fn(),
  delete:   jest.fn(),
};

const service = new UsersService(mockRepo);

test('getById throws NotFoundError when user missing', async () => {
  (mockRepo.findById as jest.Mock).mockResolvedValue(null);
  await expect(service.getById('non-existent')).rejects.toThrow(NotFoundError);
});