A production-ready Node.js backend template using Domain-Driven Design, Express, Mongoose, and Jest. Clone this whenever starting a new project.
- Tech Stack
- Project Structure
- Getting Started
- Environment Variables
- Architecture Overview
- Auth Module
- Adding a New Module
- Testing
- mongodb-memory-server: Global Setup
- Scripts Reference
- Module Registry
| Concern | Library |
|---|---|
| Runtime | Node.js + TypeScript |
| Framework | Express |
| Database | MongoDB + Mongoose |
| Validation | Zod |
| Auth | JWT (access + refresh tokens) |
| Testing | Jest + Supertest + mongodb-memory-server |
| Logging | Winston |
| Path aliases | tsconfig-paths + tsc-alias |
├── src/
│ ├── modules/ # Feature modules (one per domain)
│ │ └── auth/
│ │ ├── application/ # Use cases — no framework code here
│ │ │ ├── dto/ # Zod schemas + inferred TypeScript types
│ │ │ └── services/ # Service classes (orchestrate use cases)
│ │ ├── domain/ # Pure business logic — no Mongoose, no HTTP
│ │ │ ├── entities/ # TypeScript interfaces describing domain objects
│ │ │ ├── errors/ # Domain-specific AppError subclasses
│ │ │ └── interfaces/ # Repository contracts (interfaces only)
│ │ └── infrastructure/ # Framework + DB adapters
│ │ ├── http/ # Express controllers + routers
│ │ └── persistence/ # Mongoose models + repository implementations
│ │
│ ├── routes/
│ │ └── index.ts # Central router — mounts all module routers
│ │
│ └── shared/ # Cross-cutting concerns
│ ├── config/
│ │ ├── env.config.ts # Zod-validated env vars + config object
│ │ └── mongoose.config.ts # DB connection singleton
│ ├── database/
│ │ └── base.repository.ts # Generic CRUD base class for repositories
│ ├── errors/
│ │ └── app.error.ts # AppError base + shared error subclasses
│ ├── middlewares/
│ │ ├── authenticate.middleware.ts # JWT Bearer token verification
│ │ ├── error-handler.middleware.ts # Global error → HTTP response mapper
│ │ ├── rate-limiter.middleware.ts # express-rate-limit configs
│ │ └── validation.middleware.ts # Zod body/query/params validators
│ ├── types/
│ │ ├── common.types.ts # Pagination, ApiResponse, etc.
│ │ └── express.d.ts # req.user, req.auditInfo augmentations
│ └── utils/
│ └── logger.util.ts # Winston logger
│
├── tests/
│ ├── helpers/
│ │ ├── global-setup.ts # Starts mongodb-memory-server once for all tests
│ │ ├── global-teardown.ts # Stops mongodb-memory-server after all tests
│ │ ├── setup-env.ts # Sets process.env before any module loads
│ │ ├── test-db.ts # connectTestDB / disconnectTestDB / clearTestDB
│ │ ├── test-app.ts # Creates Express app for supertest
│ │ └── factories.ts # Test data builders
│ │
│ ├── unit/
│ │ └── modules/
│ │ ├── auth/ # AuthService unit tests (fully mocked)
│ │ └── _template/ # Copy this when adding a new module
│ │
│ ├── integration/
│ │ └── modules/
│ │ ├── auth/ # Auth HTTP integration tests (real DB)
│ │ └── _template/ # Copy this when adding a new module
│ │
│ └── e2e/ # End-to-end flows (multi-step user journeys)
│
├── uploads/ # Local file storage (gitignored)
├── logs/ # Winston log files (gitignored)
├── .env.example # Copy to .env and fill in values
├── jest.config.ts
├── tsconfig.json
└── package.json
# 1. Clone the template
git clone https://github.com/WebDeveloperGlory/ddd-template.git my-new-project
cd my-new-project
# 2. Remove the template's git history and start fresh
rm -rf .git
git init
# 3. Install dependencies
npm install
# 4. Set up your environment
cp .env.example .env
# Edit .env with your values — at minimum set MONGODB_URI and JWT_SECRET
# 5. Start the dev server
npm run dev
# 6. Run the tests
npm testCopy .env.example to .env. The env schema is validated with Zod at startup — the server won't start if a required variable is missing.
| Variable | Required | Description |
|---|---|---|
NODE_ENV |
Yes | development | production | test |
PORT |
No | Defaults to 5000 |
MONGODB_URI |
Yes | MongoDB connection string |
MONGODB_TEST_URI |
No | If set, tests use this instead of mongodb-memory-server |
JWT_SECRET |
Yes | Min 32 characters |
EMAIL_VERIFICATION_SECRET |
Yes | Min 32 characters |
JWT_EXPIRES_IN |
No | Access token TTL in seconds. Defaults to 604800 (7 days) |
ALLOWED_ORIGINS |
Yes | Comma-separated CORS origins |
See .env.example for the full list.
This template follows Domain-Driven Design with a layered architecture. The dependency rule is strict: outer layers depend on inner layers, never the reverse.
HTTP Request
│
▼
┌─────────────────────────────────────────────────┐
│ Infrastructure Layer (auth.router, auth.controller, user.model, auth.repository)
│ • Knows about Express, Mongoose, JWT │
│ • Translates HTTP ↔ application layer │
└───────────────────┬─────────────────────────────┘
│ calls
▼
┌─────────────────────────────────────────────────┐
│ Application Layer (auth.service, auth.dto) │
│ • Orchestrates use cases │
│ • Depends on domain interfaces, not Mongoose │
│ • No HTTP knowledge (no req, res) │
└───────────────────┬─────────────────────────────┘
│ depends on interfaces from
▼
┌─────────────────────────────────────────────────┐
│ Domain Layer (entities, errors, interfaces) │
│ • Pure TypeScript — zero dependencies │
│ • Defines what the business domain IS │
│ • Repository interface lives here │
└─────────────────────────────────────────────────┘
Key rules:
- Services import from
domain/interfaces, never frominfrastructure/persistence - Controllers import services, never repositories
- Domain layer has zero npm imports (no Mongoose, no Express)
- DTOs live in
application/dtoand are Zod schemas first, TypeScript types second
The included auth module provides:
| Endpoint | Method | Auth | Description |
|---|---|---|---|
/api/v1/auth/register |
POST | Public | Register with email or phone |
/api/v1/auth/login |
POST | Public | Login with email or phone |
/api/v1/auth/refresh |
POST | Public | Rotate refresh token |
/api/v1/auth/logout |
POST | Bearer | Invalidate refresh token(s) |
/api/v1/auth/me |
GET | Bearer | Get current user profile |
- Access token: Short-lived JWT (
JWT_EXPIRES_INseconds). Sent asAuthorization: Bearer <token>. - Refresh token: Long-lived JWT (30 days). Stored hashed (SHA-256) in the user's document. Never stored raw.
- Rotation: Every refresh call consumes the old token and issues a new one. Old token is deleted from DB.
- Reuse detection: If a refresh token is used but not found in the DB, the entire token family is invalidated (all sessions for that login chain). This catches stolen token scenarios.
- Logout: Single-device logout removes one token.
allDevices: truewipes all refresh tokens.
Follow these steps every time you add a new domain module (e.g., products, orders, vendors).
# Replace "products" with your module name
MODULE=products
mkdir -p src/modules/$MODULE/{application/{dto,services},domain/{entities,errors,interfaces},infrastructure/{http,persistence}}src/modules/products/domain/entities/product.entity.ts
export interface ProductEntity {
id: string;
name: string;
price: number;
ownerId: string;
createdAt: Date;
updatedAt: Date;
}src/modules/products/domain/errors/product.errors.ts
import { AppError } from '@shared/errors/app.error';
export class ProductNotFoundError extends AppError {
constructor(message = 'Product not found') {
super(message, 404, 'PRODUCT_NOT_FOUND');
}
}src/modules/products/domain/interfaces/product.repository.interface.ts
import { ProductEntity } from '../entities/product.entity';
export interface IProductRepository {
create(data: CreateProductData): Promise<ProductEntity>;
findById(id: string): Promise<ProductEntity | null>;
findByOwner(ownerId: string): Promise<ProductEntity[]>;
updateById(id: string, data: Partial<ProductEntity>): Promise<ProductEntity | null>;
deleteById(id: string): Promise<boolean>;
}
export interface CreateProductData {
name: string;
price: number;
ownerId: string;
}src/modules/products/application/dto/product.dto.ts
import { z } from 'zod';
export const CreateProductSchema = z.object({
name: z.string().min(1).max(200),
price: z.number().positive(),
});
export type CreateProductDto = z.infer<typeof CreateProductSchema>;src/modules/products/application/services/product.service.ts
import { IProductRepository } from '../../domain/interfaces/product.repository.interface';
import { ProductNotFoundError } from '../../domain/errors/product.errors';
import { CreateProductDto } from '../dto/product.dto';
export class ProductService {
constructor(private readonly productRepository: IProductRepository) {}
async create(dto: CreateProductDto, ownerId: string) {
return this.productRepository.create({ ...dto, ownerId });
}
async getById(id: string) {
const product = await this.productRepository.findById(id);
if (!product) throw new ProductNotFoundError();
return product;
}
}src/modules/products/infrastructure/persistence/product.model.ts
import mongoose, { Document, Schema } from 'mongoose';
import { ProductEntity } from '../../domain/entities/product.entity';
export interface ProductDocument extends Omit<ProductEntity, 'id'>, Document {}
const ProductSchema = new Schema<ProductDocument>(
{
name: { type: String, required: true },
price: { type: Number, required: true },
ownerId: { type: String, required: true, index: true },
},
{ timestamps: true, versionKey: false }
);
export const ProductModel = mongoose.model<ProductDocument>('Product', ProductSchema);src/modules/products/infrastructure/persistence/product.repository.ts
import { ProductModel } from './product.model';
import { IProductRepository, CreateProductData } from '../../domain/interfaces/product.repository.interface';
import { ProductEntity } from '../../domain/entities/product.entity';
export class ProductRepository implements IProductRepository {
async create(data: CreateProductData): Promise<ProductEntity> {
const doc = await ProductModel.create(data);
return this.toEntity(doc.toJSON());
}
async findById(id: string): Promise<ProductEntity | null> {
const doc = await ProductModel.findById(id).lean();
return doc ? this.toEntity(doc) : null;
}
async findByOwner(ownerId: string): Promise<ProductEntity[]> {
const docs = await ProductModel.find({ ownerId }).lean();
return docs.map(d => this.toEntity(d));
}
async updateById(id: string, data: Partial<ProductEntity>): Promise<ProductEntity | null> {
const doc = await ProductModel.findByIdAndUpdate(id, data, { new: true }).lean();
return doc ? this.toEntity(doc) : null;
}
async deleteById(id: string): Promise<boolean> {
const result = await ProductModel.findByIdAndDelete(id);
return !!result;
}
private toEntity(doc: any): ProductEntity {
return {
id: doc._id?.toString() ?? doc.id,
name: doc.name,
price: doc.price,
ownerId: doc.ownerId,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
};
}
}src/modules/products/infrastructure/http/product.controller.ts
import { Request, Response, NextFunction } from 'express';
import { ProductService } from '../../application/services/product.service';
export class ProductController {
constructor(private readonly productService: ProductService) {}
create = async (req: Request, res: Response, next: NextFunction) => {
try {
const product = await this.productService.create(req.body, req.user!.id);
res.status(201).json({ success: true, data: { product } });
} catch (error) { next(error); }
};
getById = async (req: Request, res: Response, next: NextFunction) => {
try {
const product = await this.productService.getById(req.params.id);
res.status(200).json({ success: true, data: { product } });
} catch (error) { next(error); }
};
}src/modules/products/infrastructure/http/product.router.ts
import { Router } from 'express';
import { ProductController } from './product.controller';
import { ProductService } from '../../application/services/product.service';
import { ProductRepository } from '../persistence/product.repository';
import { authenticate } from '@shared/middlewares/authenticate.middleware';
import { validateRequest } from '@shared/middlewares/validation.middleware';
import { CreateProductSchema } from '../../application/dto/product.dto';
const router = Router();
const controller = new ProductController(new ProductService(new ProductRepository()));
router.post('/', authenticate, validateRequest(CreateProductSchema), controller.create);
router.get('/:id', authenticate, controller.getById);
export { router as productRouter };src/routes/index.ts
import { productRouter } from '@modules/products/infrastructure/http/product.router';
router.use('/products', productRouter);Copy the templates:
cp -r tests/unit/modules/_template tests/unit/modules/products
cp -r tests/integration/modules/_template tests/integration/modules/products
# Rename files
mv tests/unit/modules/products/service.test.ts tests/unit/modules/products/product.service.test.ts
mv tests/integration/modules/products/routes.test.ts tests/integration/modules/products/product.routes.test.tsThen fill in the actual test cases following the patterns in the auth tests.
| Type | Location | Uses DB | Uses HTTP | Speed |
|---|---|---|---|---|
| Unit | tests/unit/ |
❌ Mocked | ❌ No | Fast |
| Integration | tests/integration/ |
✅ Real | ✅ Yes | Medium |
| E2E | tests/e2e/ |
✅ Real | ✅ Yes | Slow |
# Run all tests
npm test
# Watch mode (re-runs on save)
npm run test:watch
# Coverage report
npm run test:coverage
# Only unit tests
npm run test:unit
# Only integration tests
npm run test:integration
# A specific test file
npx jest tests/unit/modules/auth/auth.service.test.ts
# Tests matching a name pattern
npx jest --testNamePattern="register"tests/
unit/modules/<module-name>/<service-name>.service.test.ts
integration/modules/<module-name>/<module-name>.routes.test.ts
Unit tests — no DB at all. Mock everything:
const repo = makeMockRepo({ findById: jest.fn().mockResolvedValue(someEntity) });
const service = new MyService(repo);Integration and E2E tests — real DB. Use the helpers:
beforeAll(async () => { await connectTestDB(); });
afterAll(async () => { await disconnectTestDB(); });
afterEach(async () => { await clearTestDB(); }); // wipe between testsThe test setup in tests/helpers/global-setup.ts picks the DB this way:
- If
MONGODB_TEST_URIis set in.env→ use that real MongoDB URI - Otherwise → start
mongodb-memory-serverautomatically
To use a real test DB, add this to your .env:
MONGODB_TEST_URI=mongodb://localhost:27017/your-app-test
mongodb-memory-server downloads a real MongoDB binary (~80 MB) the first time it runs on a machine. On slow connections or locked-down networks it can fail. Here are two strategies to avoid per-project downloads.
Install MongoDB once on your machine, then tell every project to use that binary.
Linux / macOS — add to ~/.bashrc or ~/.zshrc:
# Use system MongoDB binary (install MongoDB server separately)
export MONGOMS_SYSTEM_BINARY=/usr/bin/mongod
# OR point to a manually placed binary
export MONGOMS_SYSTEM_BINARY=$HOME/.local/bin/mongodWindows — run once in PowerShell as Administrator:
[System.Environment]::SetEnvironmentVariable(
"MONGOMS_SYSTEM_BINARY",
"C:\Program Files\MongoDB\Server\7.0\bin\mongod.exe",
"Machine"
)After setting this, mongodb-memory-server uses the existing binary and never downloads again.
Keep one downloaded copy and share it across all projects:
Linux / macOS — add to ~/.bashrc or ~/.zshrc:
export MONGOMS_DOWNLOAD_DIR=$HOME/.cache/mongodb-binariesWindows — PowerShell as Administrator:
[System.Environment]::SetEnvironmentVariable(
"MONGOMS_DOWNLOAD_DIR",
"$env:USERPROFILE\.cache\mongodb-binaries",
"Machine"
)After the first download it will be cached there and reused by all projects.
Set MONGODB_TEST_URI in your .env:
MONGODB_TEST_URI=mongodb://localhost:27017/your-app-test
The global-setup.ts file detects this and skips mongodb-memory-server entirely.
| Script | Description |
|---|---|
npm run dev |
Start dev server with hot reload |
npm run build |
Compile TypeScript + resolve path aliases |
npm start |
Run compiled output |
npm test |
Run all tests |
npm run test:watch |
Watch mode |
npm run test:coverage |
Coverage report |
npm run test:unit |
Unit tests only |
npm run test:integration |
Integration tests only |
npm run lint |
ESLint check |
npm run lint:fix |
ESLint auto-fix |
Track your modules here as the project grows.
| Module | Status | Routes prefix | Notes |
|---|---|---|---|
| auth | ✅ Complete | /api/v1/auth |
Register, login, refresh, logout, me |
| users | 🚧 Stub | /api/v1/users |
Add when needed |
| — | — | — | Add new modules here |
Document routes here as they are added.
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/v1/auth/register |
Public | Register (email or phone) |
| POST | /api/v1/auth/login |
Public | Login (email or phone) |
| POST | /api/v1/auth/refresh |
Public | Rotate refresh token |
| POST | /api/v1/auth/logout |
Bearer | Logout (single or all devices) |
| GET | /api/v1/auth/me |
Bearer | Current user profile |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /health |
Public | Server health check |
Add new sections below as modules are built out.