Introduction
As your Node.js applications grow beyond simple CRUD operations, you'll face a common challenge: how do you keep your codebase maintainable, testable, and flexible?
Clean Architecture, introduced by Robert C. Martin (Uncle Bob), offers a proven solution. This guide will show you how to apply it to Node.js Express applications with MongoDB, when to use it, and how to migrate existing projects without rewriting everything from scratch.
What is Clean Architecture?
Clean Architecture is a software design pattern that separates your code into layers with one fundamental rule:
Dependencies point inward. Inner layers never depend on outer layers.
┌─────────────────────────────────────┐
│ Express, MongoDB, AWS │ Frameworks & Drivers
│ ┌───────────────────────────────┐ │
│ │ Controllers, Routes │ │ Interface Adapters
│ │ ┌─────────────────────────┐ │ │
│ │ │ Use Cases │ │ │ Application Logic
│ │ │ ┌───────────────────┐ │ │ │
│ │ │ │ Business Rules │ │ │ │ Domain
│ │ │ └───────────────────┘ │ │ │
│ │ └─────────────────────────┘ │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────┘
The Four Layers
- Domain (Center): Pure business logic - no framework dependencies
- Application: Use cases that orchestrate domain logic
- Infrastructure: Database, external APIs, email services
- Presentation: Express controllers, GraphQL resolvers, CLI
The Problem with Traditional Express Apps
Here's a typical Express service you've probably written:
// name=traditional-approach.ts
// services/contestService.ts
import { Contest } from '../models/Contest'; // Mongoose model
import { sendEmail } from '../utils/email';
export async function createContest(data) {
// Validation
if (data.startsAt >= data.endsAt) {
throw new Error('Invalid dates');
}
// Save to database
const contest = new Contest(data);
await contest.save();
// Send notification
await sendEmail('Contest created!');
return contest;
}
Problems:
- ❌ Business logic mixed with database code
- ❌ Can't test without MongoDB running
- ❌ Can't switch databases without rewriting everything
- ❌ Hard to reuse in CLI tools or background jobs
Clean Architecture Solution
The same feature, redesigned:
1. Domain Layer - Pure Business Logic
// name=domain/Contest.entity.ts
// No framework dependencies - just TypeScript!
export class Contest {
constructor(
public readonly id: string,
public title: string,
public startsAt: Date,
public endsAt: Date,
public status: ContestStatus
) {
this.validate();
}
private validate() {
if (this.startsAt >= this.endsAt) {
throw new DomainError('Contest must end after it starts');
}
}
isActive(): boolean {
const now = new Date();
return now >= this.startsAt && now <= this.endsAt;
}
canBeModified(): boolean {
return this.status === 'DRAFT' && this.startsAt > new Date();
}
}
export enum ContestStatus { DRAFT, ACTIVE, COMPLETED }
2. Repository Interface - Abstraction
// name=domain/IContestRepository.ts
export interface IContestRepository {
save(contest: Contest): Promise<Contest>;
findById(id: string): Promise<Contest | null>;
findActive(): Promise<Contest[]>;
}
// Implementation comes later in infrastructure layer
3. Use Case - Application Logic
// name=application/CreateContest.usecase.ts
export class CreateContestUseCase {
constructor(
private repository: IContestRepository,
private notifier: INotificationService
) {}
async execute(request: CreateContestRequest) {
// Validate
if (new Date(request.startsAt) < new Date()) {
throw new ValidationError('Cannot start in the past');
}
// Create domain entity
const contest = new Contest(
generateId(),
request.title,
new Date(request.startsAt),
new Date(request.endsAt),
ContestStatus.DRAFT
);
// Save via repository
const saved = await this.repository.save(contest);
// Notify users
await this.notifier.notify('Contest created!');
return { contestId: saved.id };
}
}
4. Infrastructure - MongoDB Implementation
// name=infrastructure/MongoContestRepository.ts
export class MongoContestRepository implements IContestRepository {
async save(contest: Contest): Promise<Contest> {
const doc = await ContestModel.create({
title: contest.title,
startsAt: contest.startsAt,
endsAt: contest.endsAt,
status: contest.status
});
return this.toDomain(doc);
}
private toDomain(doc: any): Contest {
return new Contest(
doc._id.toString(),
doc.title,
doc.startsAt,
doc.endsAt,
doc.status
);
}
}
5. Presentation - Express Controller
// name=presentation/ContestController.ts
export class ContestController {
constructor(private createUseCase: CreateContestUseCase) {}
async create(req: Request, res: Response) {
try {
const result = await this.createUseCase.execute({
title: req.body.title,
startsAt: req.body.startsAt,
endsAt: req.body.endsAt
});
res.status(201).json({ success: true, data: result });
} catch (error) {
next(error);
}
}
}
Key Benefits
1. Testability 🧪
Before: Need database for every test
// Must connect to MongoDB, seed data, cleanup
it('creates contest', async () => {
await connectDB();
const result = await service.createContest(data);
expect(result).toBeDefined();
});
After: Pure unit tests
it('creates contest', async () => {
const mockRepo = { save: jest.fn().mockResolvedValue(contest) };
const useCase = new CreateContestUseCase(mockRepo, mockNotifier);
const result = await useCase.execute(validRequest);
expect(mockRepo.save).toHaveBeenCalled();
expect(result.contestId).toBeDefined();
});
2. Flexibility 🔄
Switch from MongoDB to PostgreSQL? Just swap the repository:
// name=switching-databases.ts
// Create PostgreSQL implementation
class PostgresContestRepository implements IContestRepository {
async save(contest: Contest) {
// PostgreSQL logic here
}
}
// Change dependency injection
container.bind('ContestRepository').to(PostgresContestRepository);
// ✅ Use cases, controllers, domain - unchanged!
3. Reusability ♻️
Same business logic, multiple interfaces:
// name=reusability.ts
// REST API
app.post('/contests', (req, res) => {
const result = await createContestUseCase.execute(req.body);
res.json(result);
});
// GraphQL
const resolvers = {
createContest: (_, args) => createContestUseCase.execute(args)
};
// CLI
program.command('create-contest')
.action((opts) => createContestUseCase.execute(opts));
// Background Job
queue.process('create-contest', (job) =>
createContestUseCase.execute(job.data)
);
When to Use Clean Architecture
✅ Use When:
- Complex business logic that changes frequently
- Long-term projects (2+ years lifespan)
- Team of 3+ developers
- Multiple interfaces (REST + GraphQL + CLI)
- Compliance requirements (auditing, testing)
- Expected to scale to microservices
❌ Avoid When:
- Simple CRUD apps with no business logic
- Prototypes or MVPs
- Solo developer on a small project
- Short-lived projects (<6 months)
- Static content APIs
The Sweet Spot
Perfect for: E-commerce platforms, fintech apps, SaaS products, educational platforms, healthcare systems
Overkill for: Todo apps, personal blogs, simple dashboards
Project Structure
src/
├── domain/ # Business rules (no dependencies)
│ ├── entities/
│ │ └── Contest.ts
│ └── repositories/
│ └── IContestRepository.ts
│
├── application/ # Use cases
│ ├── use-cases/
│ │ └── CreateContest.usecase.ts
│ └── dto/
│ └── ContestDto.ts
│
├── infrastructure/ # External tools
│ ├── database/
│ │ └── mongodb/
│ │ ├── models/
│ │ └── repositories/
│ └── services/
│ └── EmailService.ts
│
├── presentation/ # Controllers
│ └── http/
│ ├── controllers/
│ └── routes/
│
└── main/ # Composition root
├── container.ts # Dependency injection
└── server.ts
Migration Strategy
Don't rewrite everything at once! Follow this incremental approach:
Week 1-2: Setup & Pilot Feature
- Choose ONE feature (e.g., contest management)
- Create new directory structure
- Extract domain entity from Mongoose model
- Define repository interface
Week 3-4: Application Layer
- Convert service functions to use cases
- Write unit tests for use cases
- Create DTOs for inputs/outputs
Week 5-6: Infrastructure & Presentation
- Implement MongoDB repository
- Refactor controller to use use case
- Integration tests
Week 7+: Expand & Iterate
- Migrate next feature
- Refine patterns based on learnings
- Document for team
Key principle: Keep old code working while building new structure alongside it.
Common Pitfalls & Solutions
| Pitfall | Why It Happens | Solution |
|---|---|---|
| Over-abstracting | Creating interfaces for everything | Only abstract at architectural boundaries |
| Anemic domain | Entities with no logic, just data | Move business rules from use cases to entities |
| Use case explosion | One use case per endpoint | Combine related operations when appropriate |
| Ignoring YAGNI | Building for hypothetical future | Start simple, add layers as complexity grows |
| Poor naming | Generic names like "Manager", "Handler" | Use domain language: PublishContest, not ContestPublisher
|
Real-World Example: Contest Platform
Here's a complete flow showing how layers interact:
// name=complete-flow-example.ts
// 1. HTTP Request comes in
POST /api/contests
{
"title": "Math Challenge",
"startsAt": "2024-12-01T10:00:00Z",
"endsAt": "2024-12-01T12:00:00Z"
}
// 2. Controller receives request
class ContestController {
async create(req, res) {
const result = await this.createContestUseCase.execute({
title: req.body.title,
startsAt: req.body.startsAt,
endsAt: req.body.endsAt,
userId: req.user.id
});
res.json(result);
}
}
// 3. Use Case orchestrates
class CreateContestUseCase {
async execute(request) {
// Create entity (validates business rules)
const contest = Contest.create(
request.title,
new Date(request.startsAt),
new Date(request.endsAt),
request.userId
);
// Save via repository
const saved = await this.repository.save(contest);
// Side effects
await this.notifier.notify('Contest created');
return { contestId: saved.id };
}
}
// 4. Repository saves to MongoDB
class MongoContestRepository {
async save(contest) {
const doc = await ContestModel.create({
title: contest.title,
startsAt: contest.startsAt,
endsAt: contest.endsAt
});
return this.toDomain(doc);
}
}
Flow:
- Express → Controller (presentation)
- Controller → Use Case (application)
- Use Case → Entity (domain) + Repository (infrastructure)
- Repository → MongoDB (external)
Dependency Injection
Wire everything together in one place:
// name=dependency-injection.ts
// main/container.ts
import { Container } from 'inversify';
const container = new Container();
// Repositories
container.bind<IContestRepository>('ContestRepository')
.to(MongoContestRepository);
// Use Cases
container.bind<CreateContestUseCase>('CreateContestUseCase')
.toDynamicValue(ctx => new CreateContestUseCase(
ctx.container.get('ContestRepository'),
ctx.container.get('NotificationService')
));
// Controllers
container.bind<ContestController>('ContestController')
.toDynamicValue(ctx => new ContestController(
ctx.container.get('CreateContestUseCase')
));
export { container };
Testing Strategy
Unit Tests (Fast, Isolated)
- Domain entities: Pure logic, no mocks needed
- Use cases: Mock dependencies (repositories, services)
- Run in milliseconds
Integration Tests (Realistic)
- Repositories: Test with real database (use test container)
- Run in seconds
E2E Tests (Complete Flow)
- HTTP → Controller → Use Case → Repository → Database
- Run in minutes
Conclusion
Clean Architecture transforms chaotic codebases into maintainable, testable systems. The key benefits:
✅ Test business logic without databases
✅ Swap databases/frameworks easily
✅ Reuse logic across REST, GraphQL, CLI
✅ Scale to microservices naturally
✅ Onboard new developers faster
Start small: Pick one feature, prove the pattern works, then expand. Don't rewrite everything overnight.
Remember: Architecture is about managing complexity. If your app isn't complex, you don't need this. But when it grows, Clean Architecture will save you months of technical debt.
Further Reading:
- Robert C. Martin's "Clean Architecture" book
- DDD (Domain-Driven Design) by Eric Evans
- SOLID principles
- Hexagonal Architecture (Ports & Adapters)
This article used a real educational platform (SkillBridge) as reference, showing how Clean Architecture principles apply to production Node.js applications.
Top comments (0)