Skip to main content
Miraç AĞCABAY
Tüm yazılar
Node.jsArchitectureBackend

Clean Architecture Patterns for Node.js

10 Şubat 2025  ·  7 dk okuma

Most Node.js projects start the same way: a few routes, some middleware, maybe an ORM. A year later, the routes call the ORM directly, business logic lives in middlewares, and changing a database column requires touching six files.

The problem isn't the code — it's the architecture. Specifically, the absence of it.

The Dependency Problem

Express route handlers that directly import Prisma (or Mongoose, or pg) create a hidden coupling: your business logic depends on your infrastructure. When you want to test a use case, you need a database. When you want to swap Prisma for another ORM, you rewrite business logic.

The Dependency Inversion Principle says: high-level modules (business logic) should not depend on low-level modules (database drivers). Both should depend on abstractions.

// ❌ Route handler coupled to infrastructure
app.post("/orders", async (req, res) => {
  const order = await prisma.order.create({
    data: { userId: req.user.id, items: req.body.items },
  });
  await sendEmail(order.userEmail, "Order confirmed");
  res.json(order);
});
// ✅ Route delegates to a use case
app.post("/orders", async (req, res) => {
  const result = await createOrder.execute({
    userId: req.user.id,
    items: req.body.items,
  });
  res.json(result);
});

The Repository Pattern

A repository provides a collection-like interface to your persistence layer. It hides whether data lives in Postgres, Redis, or an in-memory map.

// The abstraction — lives in the domain layer
interface OrderRepository {
  save(order: Order): Promise<void>;
  findById(id: string): Promise<Order | null>;
  findByUserId(userId: string): Promise<Order[]>;
}
 
// The implementation — lives in the infrastructure layer
class PrismaOrderRepository implements OrderRepository {
  constructor(private readonly db: PrismaClient) {}
 
  async save(order: Order): Promise<void> {
    await this.db.order.upsert({
      where: { id: order.id },
      create: OrderMapper.toPersistence(order),
      update: OrderMapper.toPersistence(order),
    });
  }
 
  async findById(id: string): Promise<Order | null> {
    const row = await this.db.order.findUnique({ where: { id } });
    return row ? OrderMapper.toDomain(row) : null;
  }
}

The OrderMapper converts between the domain model (Order) and the persistence model (Prisma's generated type). Domain models should not be ORM entities — they should be plain classes or value objects with behavior.

Use Cases as Boundaries

A use case (application service) encapsulates a single unit of business logic. It depends only on domain objects and repository interfaces — never on Express, HTTP, or database clients.

class CreateOrder {
  constructor(
    private readonly orders: OrderRepository,
    private readonly notifications: NotificationService,
    private readonly events: EventBus,
  ) {}
 
  async execute(input: CreateOrderInput): Promise<CreateOrderResult> {
    const order = Order.create({
      id: generateId(),
      userId: input.userId,
      items: input.items.map(LineItem.from),
    });
 
    if (!order.isValid()) {
      throw new DomainError("Invalid order: minimum item count not met");
    }
 
    await this.orders.save(order);
    await this.notifications.orderConfirmed(order);
    this.events.publish(new OrderCreated(order));
 
    return { orderId: order.id };
  }
}

This use case is unit-testable in isolation — pass in mock repositories and notification services, no database needed.

Folder Structure That Scales

src/
├── domain/
│   ├── order/
│   │   ├── Order.ts          ← entity with behavior
│   │   ├── LineItem.ts       ← value object
│   │   └── OrderRepository.ts ← interface
│   └── shared/
│       └── DomainError.ts
├── application/
│   └── order/
│       ├── CreateOrder.ts    ← use case
│       └── CancelOrder.ts
├── infrastructure/
│   ├── persistence/
│   │   └── PrismaOrderRepository.ts
│   ├── email/
│   │   └── ResendNotificationService.ts
│   └── messaging/
│       └── RedisEventBus.ts
└── interface/
    └── http/
        └── routes/
            └── orderRoutes.ts

The rule: dependencies only point inward. interface depends on application. application depends on domain. infrastructure depends on domain. domain depends on nothing.

When Not to Do This

This architecture pays off when:

  • Multiple adapters are plausible (different databases, message brokers)
  • Business logic is complex enough to warrant isolation
  • The team has more than two people

For a CRUD API with two tables and no business rules, this is over-engineering. Start simple, extract boundaries when the complexity demands it. Architecture is a response to pressure, not a precondition.

The clearest signal it's time to restructure: your tests need a database to run, or changing an external dependency requires touching domain logic. Either of those is the architecture telling you something.