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.