DEV Community

Cover image for Mastering Clean Architecture in Node.js: A Practical Guide for Express and MongoDB
Josh Batey
Josh Batey Subscriber

Posted on

Mastering Clean Architecture in Node.js: A Practical Guide for Express and MongoDB

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
│  │  │  └───────────────────┘  │  │  │
│  │  └─────────────────────────┘  │  │
│  └───────────────────────────────┘  │
└─────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

The Four Layers

  1. Domain (Center): Pure business logic - no framework dependencies
  2. Application: Use cases that orchestrate domain logic
  3. Infrastructure: Database, external APIs, email services
  4. 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;
}
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 };
  }
}
Enter fullscreen mode Exit fullscreen mode

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
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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)
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Migration Strategy

Don't rewrite everything at once! Follow this incremental approach:

Week 1-2: Setup & Pilot Feature

  1. Choose ONE feature (e.g., contest management)
  2. Create new directory structure
  3. Extract domain entity from Mongoose model
  4. Define repository interface

Week 3-4: Application Layer

  1. Convert service functions to use cases
  2. Write unit tests for use cases
  3. Create DTOs for inputs/outputs

Week 5-6: Infrastructure & Presentation

  1. Implement MongoDB repository
  2. Refactor controller to use use case
  3. Integration tests

Week 7+: Expand & Iterate

  1. Migrate next feature
  2. Refine patterns based on learnings
  3. 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);
  }
}
Enter fullscreen mode Exit fullscreen mode

Flow:

  1. Express → Controller (presentation)
  2. Controller → Use Case (application)
  3. Use Case → Entity (domain) + Repository (infrastructure)
  4. 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 };
Enter fullscreen mode Exit fullscreen mode

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)