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

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

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.