- Endless spaghetti code spread across controllers and services.
- Business logic mixed with database queries.
- Every bug fix breaking something completely unrelated.
That's where Domain-Driven Design (DDD) comes to the rescue.
DDD isn't just a fancy buzzword — it's a mindset and methodology for organizing complex software. In this post, I'll walk you through what DDD is, why it matters in Node.js projects, and how to implement it step by step with examples.
What is Domain-Driven Design (DDD)?
At its core, DDD is about:
- Focusing on the business domain (the real-world problems your app solves).
- Structuring your code around domain concepts (users, orders, payments) instead of technical layers.
- Creating a shared language between developers and business stakeholders.
Instead of thinking in terms of controllers, services, repositories, you think in terms of domains, entities, and use cases.
Why Use DDD in Node.js?
Large Node.js projects often grow messy because of:
- Rapid feature additions.
- Too much logic in controllers.
- Mixed responsibilities.
DDD helps by: ✅ Keeping business logic clean and testable. ✅ Making it easier to scale your team — devs can own specific domains. ✅ Allowing future flexibility (swap DBs, frameworks, or message queues without rewriting core logic).
Core Building Blocks of DDD
Here are the main DDD concepts and how they translate into Node.js projects:
-
Entity → An object with identity (e.g.,
User,Order). -
Value Object → Immutable data without identity (e.g.,
Email,Price). -
Aggregate → A cluster of entities treated as one unit (e.g.,
OrderwithOrderItems). - Repository → Interface for data access (DB is an implementation detail).
-
Service / Use Case → Business logic operations (e.g.,
PlaceOrder). -
Domain Events → Something that happened in the domain (e.g.,
OrderPlaced).
Node.js Project Structure with DDD
Here's how you might structure a DDD-inspired Node.js project:
src/ ├── modules/ │ ├── users/ │ │ ├── domain/ │ │ │ ├── User.js │ │ │ ├── Email.js │ │ ├── application/ │ │ │ ├── RegisterUser.js │ │ ├── infrastructure/ │ │ │ ├── MongoUserRepository.js │ │ └── index.js │ ├── orders/ │ │ ├── domain/ │ │ ├── application/ │ │ ├── infrastructure/ ├── shared/ │ ├── domain/ │ └── utils/Notice how each domain (like
users,orders) has its own domain, application, and infrastructure layers.Example: User Registration in DDD
1. Entity: User
// modules/users/domain/User.js export class User { constructor({ id, name, email }) { this.id = id; this.name = name; this.email = email; } }2. Value Object: Email
// modules/users/domain/Email.js export class Email { constructor(address) { if (!address.includes("@")) throw new Error("Invalid email"); this.address = address; } }3. Repository Interface
// modules/users/domain/UserRepository.js export class UserRepository { async save(user) { throw new Error("Method not implemented"); }
async findByEmail(email) {
throw new Error("Method not implemented");
}
}
4. Application Service (Use Case): RegisterUser
// modules/users/application/RegisterUser.js
export class RegisterUser {
constructor(userRepository) {
this.userRepository = userRepository;
}
async execute({ name, email }) {
const existing = await this.userRepository.findByEmail(email);
if (existing) throw new Error("User already exists");
<span class="hljs-keyword">const</span> user = { <span class="hljs-attr">id</span>: <span class="hljs-title class_">Date</span>.<span class="hljs-title function_">now</span>().<span class="hljs-title function_">toString</span>(), name, email };
<span class="hljs-keyword">await</span> <span class="hljs-variable language_">this</span>.<span class="hljs-property">userRepository</span>.<span class="hljs-title function_">save</span>(user);
<span class="hljs-keyword">return</span> user;
}
}
5. Infrastructure: MongoDB Repository Implementation
// modules/users/infrastructure/MongoUserRepository.js
import { UserRepository } from "../domain/UserRepository.js";
export class MongoUserRepository extends UserRepository {
constructor(mongoClient) {
super();
this.collection = mongoClient.db().collection("users");
}
async save(user) {
await this.collection.insertOne(user);
}
async findByEmail(email) {
return await this.collection.findOne({ email });
}
}
6. Connecting Everything
import express from "express";
import { MongoClient } from "mongodb";
import { MongoUserRepository } from "./modules/users/infrastructure/MongoUserRepository.js";
import { RegisterUser } from "./modules/users/application/RegisterUser.js";
async function main() {
const app = express();
app.use(express.json());
const mongo = new MongoClient("mongodb://localhost:27017");
await mongo.connect();
const userRepository = new MongoUserRepository(mongo);
const registerUser = new RegisterUser(userRepository);
app.post("/register", async (req, res) => {
try {
const user = await registerUser.execute(req.body);
res.status(201).json(user);
} catch (err) {
res.status(400).json({ error: err.message });
}
});
app.listen(3000, () => console.log("Server running on port 3000"));
}
main();
Best Practices for DDD in Node.js
-
Keep domains independent → No direct calls between
usersandorders. Use events or application services. - Separate concerns → Business logic (domain) should not depend on infrastructure (DB, HTTP).
- Use Value Objects generously → They enforce domain rules early.
-
Introduce Domain Events → Let domains communicate via events (
UserRegistered,OrderPlaced). - Think in Ubiquitous Language → Name your classes, methods, and events in a way business folks understand.
Final Thoughts
Domain-Driven Design might feel like extra ceremony when you start, but for large projects, it's a lifesaver. You'll end up with:
- Modular architecture that scales with teams.
- Maintainable business logic that's testable and framework-agnostic.
- Future-proof flexibility — swap Express, MongoDB, or Redis without touching core logic.
So next time you're kicking off a big Node.js project, try structuring it with DDD. It'll keep your codebase healthy for years to come.
Author: Somendradev
Top comments (0)