Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Node.js DDD Backend Template

A production-ready Node.js backend template using Domain-Driven Design, Express, Mongoose, and Jest. Clone this whenever starting a new project.


Table of Contents


Tech Stack

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

Project Structure

├── 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

Getting Started

# 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 test

Environment Variables

Copy .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.


Architecture Overview

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 from infrastructure/persistence
  • Controllers import services, never repositories
  • Domain layer has zero npm imports (no Mongoose, no Express)
  • DTOs live in application/dto and are Zod schemas first, TypeScript types second

Auth Module

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

Token Strategy

  • Access token: Short-lived JWT (JWT_EXPIRES_IN seconds). Sent as Authorization: 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: true wipes all refresh tokens.

Adding a New Module

Follow these steps every time you add a new domain module (e.g., products, orders, vendors).

1. Create the folder structure

# Replace "products" with your module name
MODULE=products

mkdir -p src/modules/$MODULE/{application/{dto,services},domain/{entities,errors,interfaces},infrastructure/{http,persistence}}

2. Define the domain entity

src/modules/products/domain/entities/product.entity.ts

export interface ProductEntity {
    id: string;
    name: string;
    price: number;
    ownerId: string;
    createdAt: Date;
    updatedAt: Date;
}

3. Define domain errors

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');
    }
}

4. Define the repository interface

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;
}

5. Create the Zod DTOs

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>;

6. Create the service

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;
    }
}

7. Create the Mongoose model

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);

8. Implement the repository

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,
        };
    }
}

9. Create the controller and router

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 };

10. Register the router

src/routes/index.ts

import { productRouter } from '@modules/products/infrastructure/http/product.router';

router.use('/products', productRouter);

11. Add tests

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.ts

Then fill in the actual test cases following the patterns in the auth tests.


Testing

Test Types

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

Running Tests

# 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"

Test File Naming Convention

tests/
  unit/modules/<module-name>/<service-name>.service.test.ts
  integration/modules/<module-name>/<module-name>.routes.test.ts

DB Strategy per Test Type

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 tests

Test DB Selection

The test setup in tests/helpers/global-setup.ts picks the DB this way:

  1. If MONGODB_TEST_URI is set in .env → use that real MongoDB URI
  2. Otherwise → start mongodb-memory-server automatically

To use a real test DB, add this to your .env:

MONGODB_TEST_URI=mongodb://localhost:27017/your-app-test

mongodb-memory-server: Global Setup

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.

Option A: Point to a shared binary (recommended)

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/mongod

Windows — 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.

Option B: Shared download cache

Keep one downloaded copy and share it across all projects:

Linux / macOS — add to ~/.bashrc or ~/.zshrc:

export MONGOMS_DOWNLOAD_DIR=$HOME/.cache/mongodb-binaries

Windows — 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.

Option C: Use a real test DB (no binary download at all)

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.


Scripts Reference

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

Module Registry

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

Route Registry

Document routes here as they are added.

Auth

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

Health

Method Path Auth Description
GET /health Public Server health check

Add new sections below as modules are built out.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages