The first backend I built for a university project was a single 800-line app.js file. Express routes, Mongoose queries, business logic, and email-sending code all tangled together. It worked — until it didn't. The moment I needed to add unit tests or swap MongoDB for PostgreSQL, I realised I had not built a backend; I had built a ball of mud.
Clean Architecture, popularised by Robert C. Martin, is a set of principles that keep your business logic independent of frameworks, databases, and delivery mechanisms. Here is how I apply it to every Node.js/Express project I build today.
The Core Principle: Dependency Rule
The Dependency Rule states: source code dependencies must point inward only. Your business logic (domain layer) must not know anything about Express, MongoDB, or any external library. The outer layers (HTTP, database) depend on the inner layers — never the other way around.
In practice, this means your UserService doesn't call User.findById() directly. It calls a UserRepository interface, and MongoDB implements that interface externally.
My Folder Structure
├── config/
│ ├── db.js # MongoDB connection
│ └── env.js # Env var validation
├── domain/
│ ├── entities/ # Pure JS classes, no framework deps
│ └── errors/ # Domain-specific error types
├── application/
│ ├── services/ # Business logic (UserService, ExpenseService)
│ └── use-cases/ # Optional: one-function use-case files
├── infrastructure/
│ ├── repositories/ # MongoDB implementations
│ ├── email/ # Nodemailer adapter
│ └── cache/ # Redis adapter
├── interfaces/
│ ├── controllers/ # Express route handlers
│ ├── middleware/ # Auth, validation, error handling
│ └── routes/ # Route definitions
└── app.js # Express app factory
Layer 1: Domain Entities
Domain entities are plain JavaScript classes with no framework dependencies. They encode business rules:
// domain/entities/Expense.js
class Expense {
constructor({ id, groupId, payerId, amount, description, splitType, splits }) {
if (amount <= 0) throw new Error('Expense amount must be positive');
if (!['equal', 'percentage', 'exact'].includes(splitType)) {
throw new Error('Invalid split type');
}
this.id = id;
this.groupId = groupId;
this.payerId = payerId;
this.amount = amount;
this.description = description;
this.splitType = splitType;
this.splits = splits;
this.createdAt = new Date();
}
// Business rule: validate splits sum to 100% for percentage type
validateSplits() {
if (this.splitType === 'percentage') {
const total = this.splits.reduce((sum, s) => sum + s.percentage, 0);
if (Math.abs(total - 100) > 0.01) throw new Error('Splits must total 100%');
}
}
}
module.exports = Expense;
Notice: no Mongoose, no Express, no imports from infrastructure. This class can be unit-tested with zero mocking.
Layer 2: Repository Interfaces
JavaScript lacks formal interfaces, but I define them as abstract classes with documented contracts:
// domain/repositories/IExpenseRepository.js
class IExpenseRepository {
async findById(id) { throw new Error('Not implemented'); }
async findByGroupId(groupId, options) { throw new Error('Not implemented'); }
async create(expenseData) { throw new Error('Not implemented'); }
async update(id, data) { throw new Error('Not implemented'); }
async delete(id) { throw new Error('Not implemented'); }
}
module.exports = IExpenseRepository;
Layer 3: Application Services
Services receive their dependencies through constructor injection — no hard imports of infrastructure:
// application/services/ExpenseService.js
const Expense = require('../../domain/entities/Expense');
const { minimiseTransactions } = require('../algorithms/settlement');
class ExpenseService {
constructor(expenseRepo, groupRepo, notificationService) {
this.expenseRepo = expenseRepo; // injected
this.groupRepo = groupRepo; // injected
this.notificationService = notificationService; // injected
}
async createExpense(groupId, payerId, data) {
const group = await this.groupRepo.findById(groupId);
if (!group) throw new AppError('Group not found', 404, 'GROUP_NOT_FOUND');
const expense = new Expense({ groupId, payerId, ...data });
expense.validateSplits();
const saved = await this.expenseRepo.create(expense);
await this.notificationService.notifyGroup(groupId, 'expense:created', saved);
return saved;
}
}
Layer 4: Infrastructure (MongoDB Repository)
// infrastructure/repositories/MongoExpenseRepository.js
const IExpenseRepository = require('../../domain/repositories/IExpenseRepository');
const ExpenseModel = require('../models/ExpenseModel');
class MongoExpenseRepository extends IExpenseRepository {
async findById(id) {
return ExpenseModel.findById(id).lean();
}
async create(expense) {
const doc = await ExpenseModel.create(expense);
return doc.toObject();
}
// ... other methods
}
module.exports = MongoExpenseRepository;
Layer 5: Dependency Injection via Composition Root
I wire everything together in a single composition root — the only place that knows about all layers:
// app.js (composition root)
const MongoExpenseRepository = require('./infrastructure/repositories/MongoExpenseRepository');
const ExpenseService = require('./application/services/ExpenseService');
const ExpenseController = require('./interfaces/controllers/ExpenseController');
// Compose
const expenseRepo = new MongoExpenseRepository();
const expenseService = new ExpenseService(expenseRepo, groupRepo, notificationService);
const expenseController = new ExpenseController(expenseService);
// Wire routes
app.use('/api/v1/expenses', expenseController.router);
To swap MongoDB for PostgreSQL, I create a PgExpenseRepository implementing the same interface and change one line in app.js. The service layer is completely unaffected.
SOLID Principles in Practice
- S – Single Responsibility: Each class does one thing.
ExpenseServicehandles business logic;MongoExpenseRepositoryhandles persistence. - O – Open/Closed: Add a new payment gateway by creating a new adapter class, not by modifying existing code.
- L – Liskov Substitution: Any repository implementing
IExpenseRepositoryis substitutable. - I – Interface Segregation: Keep repository interfaces small and focused.
- D – Dependency Inversion: Services depend on abstractions (interfaces), not MongoDB directly.
See this architecture in action: How I built the PerSplit expense app using this layered approach with Socket.IO and MongoDB.
Is This Overkill for Small Projects?
For a solo weekend project with 5 routes — yes, probably. But the moment you have 2+ developers, need unit tests, or plan to scale, this structure pays enormous dividends. The discipline of separating concerns forces you to think about your domain model clearly, which produces better APIs and fewer bugs regardless of project size.
Conclusion
Clean Architecture is not a rigid methodology — it is a set of principles that guide structure. The goal is a codebase where the business rules can be understood and tested independently of any framework or database. Every Node.js backend I build today follows this layered approach, and it has dramatically reduced the time I spend debugging and refactoring.
If you are building backend systems and want a developer who thinks about architecture from day one, get in touch.