О насБлогКонтакты
Разработка18 апреля 2026 г. 5 мин 3

Чистая архитектура в Node.js: как организовать код чтобы не стать заложником

AunimedaAunimeda
📋 Содержание

Чистая архитектура в Node.js: как организовать код чтобы не стать заложником

Типичный путь Node.js проекта: всё начинается красиво, через 6 месяцев бизнес-логика размазана по контроллерам, сервисы напрямую импортируют Prisma, тесты требуют поднимать реальную базу данных. Это решаемо — если с самого начала применить несколько принципов.


Главный принцип

Бизнес-логика не должна зависеть от базы данных, фреймворка или любого внешнего сервиса.

Ваш код обработки заказов не должен знать что существует Prisma или Express. Он должен знать только бизнес-правила: как рассчитать стоимость, можно ли отменить заказ, что происходит при оплате.

Почему это важно:

  • Можно тестировать бизнес-логику без базы данных
  • Можно поменять Prisma на Drizzle не трогая бизнес-логику
  • Логика в одном месте, а не размазана по хендлерам

Структура проекта

src/
├── domain/              # Чистая бизнес-логика — никаких зависимостей
│   ├── entities/        # Сущности с методами
│   │   └── Order.ts
│   └── repositories/   # Интерфейсы (что умеет репозиторий)
│       └── IOrderRepository.ts
│
├── application/         # Use cases — что делает приложение
│   └── orders/
│       ├── CreateOrder.ts
│       └── CancelOrder.ts
│
├── infrastructure/      # Конкретные реализации
│   └── database/
│       └── PrismaOrderRepository.ts
│
└── presentation/        # Express routes и controllers
    └── http/
        └── OrderController.ts

Правило зависимостей: domainapplicationinfrastructure/presentation


Доменная сущность

// src/domain/entities/Order.ts
export class Order {
  constructor(
    readonly id: string,
    readonly userId: string,
    private _items: OrderItem[],
    private _status: 'pending' | 'paid' | 'shipped' | 'cancelled' = 'pending',
  ) {}

  get status() { return this._status; }
  get items() { return [...this._items]; }
  get total() {
    return this._items.reduce((sum, i) => sum + i.price * i.quantity, 0);
  }

  cancel() {
    if (this._status !== 'pending') {
      throw new Error(`Нельзя отменить заказ со статусом "${this._status}"`);
    }
    this._status = 'cancelled';
  }

  markPaid() {
    if (this._status !== 'pending') {
      throw new Error(`Заказ уже обработан`);
    }
    this._status = 'paid';
  }
}

export interface OrderItem {
  productId: string;
  name: string;
  price: number;
  quantity: number;
}

Никаких импортов. Чистый TypeScript. Тестируется мгновенно.


Интерфейс репозитория

// src/domain/repositories/IOrderRepository.ts
import { Order } from '../entities/Order';

export interface IOrderRepository {
  findById(id: string): Promise<Order | null>;
  findByUserId(userId: string): Promise<Order[]>;
  save(order: Order): Promise<void>;
  update(order: Order): Promise<void>;
}

Это контракт. Application layer зависит от этого интерфейса — не от Prisma.


Use Case

// src/application/orders/CreateOrder.ts
import { Order } from '../../domain/entities/Order';
import { IOrderRepository } from '../../domain/repositories/IOrderRepository';
import { IProductRepository } from '../../domain/repositories/IProductRepository';
import { randomUUID } from 'crypto';

export class CreateOrderUseCase {
  constructor(
    private orders: IOrderRepository,
    private products: IProductRepository,
  ) {}

  async execute(input: { userId: string; items: Array<{ productId: string; quantity: number }> }) {
    // Проверяем наличие и собираем данные продуктов
    const orderItems = await Promise.all(
      input.items.map(async (item) => {
        const product = await this.products.findById(item.productId);
        if (!product) throw new Error(`Продукт ${item.productId} не найден`);
        if (product.stock < item.quantity) throw new Error(`Недостаточно товара: ${product.name}`);
        return { productId: product.id, name: product.name, price: product.price, quantity: item.quantity };
      })
    );

    const order = new Order(randomUUID(), input.userId, orderItems);
    await this.orders.save(order);

    return { orderId: order.id, total: order.total };
  }
}

Тест без базы данных:

describe('CreateOrderUseCase', () => {
  it('создаёт заказ с правильной суммой', async () => {
    const orders = { save: jest.fn(), findById: jest.fn(), findByUserId: jest.fn(), update: jest.fn() };
    const products = {
      findById: jest.fn().mockResolvedValue({ id: 'p1', name: 'Товар', price: 1000, stock: 5 }),
    };

    const useCase = new CreateOrderUseCase(orders, products);
    const result = await useCase.execute({ userId: 'u1', items: [{ productId: 'p1', quantity: 3 }] });

    expect(result.total).toBe(3000);
    expect(orders.save).toHaveBeenCalledTimes(1);
  });
});

Реализация репозитория (Prisma)

// src/infrastructure/database/PrismaOrderRepository.ts
import { PrismaClient } from '@prisma/client';
import { Order } from '../../domain/entities/Order';
import { IOrderRepository } from '../../domain/repositories/IOrderRepository';

export class PrismaOrderRepository implements IOrderRepository {
  constructor(private prisma: PrismaClient) {}

  async save(order: Order): Promise<void> {
    await this.prisma.order.create({
      data: {
        id: order.id,
        userId: order.userId,
        status: order.status,
        total: order.total,
        items: {
          createMany: {
            data: order.items.map(i => ({
              productId: i.productId,
              name: i.name,
              price: i.price,
              quantity: i.quantity,
            })),
          },
        },
      },
    });
  }

  async findById(id: string): Promise<Order | null> {
    const record = await this.prisma.order.findUnique({
      where: { id },
      include: { items: true },
    });
    return record ? new Order(record.id, record.userId, record.items, record.status as any) : null;
  }

  async findByUserId(userId: string): Promise<Order[]> {
    const records = await this.prisma.order.findMany({
      where: { userId },
      include: { items: true },
    });
    return records.map(r => new Order(r.id, r.userId, r.items, r.status as any));
  }

  async update(order: Order): Promise<void> {
    await this.prisma.order.update({ where: { id: order.id }, data: { status: order.status } });
  }
}

Composition Root

// src/container.ts
import { PrismaClient } from '@prisma/client';
import { PrismaOrderRepository } from './infrastructure/database/PrismaOrderRepository';
import { PrismaProductRepository } from './infrastructure/database/PrismaProductRepository';
import { CreateOrderUseCase } from './application/orders/CreateOrderUseCase';
import { CancelOrderUseCase } from './application/orders/CancelOrderUseCase';

const prisma = new PrismaClient();
const orderRepo = new PrismaOrderRepository(prisma);
const productRepo = new PrismaProductRepository(prisma);

export const useCases = {
  createOrder: new CreateOrderUseCase(orderRepo, productRepo),
  cancelOrder: new CancelOrderUseCase(orderRepo),
};
// src/presentation/http/OrderController.ts
import { useCases } from '../../container';
import { z } from 'zod';

export async function createOrder(req: Request, res: Response) {
  const schema = z.object({
    items: z.array(z.object({ productId: z.string().uuid(), quantity: z.number().int().min(1) })),
  });
  
  const parsed = schema.safeParse(req.body);
  if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });

  try {
    const result = await useCases.createOrder.execute({ userId: req.user.id, items: parsed.data.items });
    res.status(201).json(result);
  } catch (err: any) {
    res.status(422).json({ error: err.message });
  }
}

Когда применять

Этот подход добавляет слои. Применяйте его когда:

  • Бизнес-логика начинает усложняться
  • Нужно тестировать логику без базы данных
  • Несколько источников вызова (HTTP + очередь задач)
  • Команда больше одного разработчика

Для простого CRUD — хорошо организованный Express без лишних слоёв достаточно.


Aunimeda проектирует масштабируемые Node.js архитектуры для production. Обсудим ваш проект.

Смотрите также: Next.js 15 Server Components, Мониторинг Node.js production

Читайте также

WebSockets vs SSE vs Long Polling: выбор технологии realtime для вашего приложенияaunimeda
Разработка

WebSockets vs SSE vs Long Polling: выбор технологии realtime для вашего приложения

Чат, уведомления, live-обновления заказов, онлайн-счётчики — все они требуют realtime. WebSocket, Server-Sent Events и Long Polling работают по-разному. Разбираем когда каждый подход лучше, с реальным кодом на Node.js.

Supabase vs Firebase: что выбрать для стартапа в Бишкекеaunimeda
Разработка

Supabase vs Firebase: что выбрать для стартапа в Бишкеке

Supabase — open-source с PostgreSQL, самохостинг возможен. Firebase — зрелый Google BaaS. PocketBase — один бинарник для MVP. Сравниваем модели данных, цены, realtime и когда каждый вариант оправдан для бишкекских стартапов.

Вайб-кодинг в Бишкеке: как местные разработчики используют ИИ для ускорения работы в 2026aunimeda
Разработка

Вайб-кодинг в Бишкеке: как местные разработчики используют ИИ для ускорения работы в 2026

Разработчики в Бишкеке используют Cursor AI, Claude Code и ChatGPT не как поиск по Stack Overflow, а как полноценного соавтора кода. Что изменилось, кто выигрывает и как не потерять квалификацию.

Нужна IT-разработка для вашего бизнеса?

Разрабатываем сайты, мобильные приложения и AI-решения для бизнеса в Кыргызстане. Бесплатная консультация.

Получить консультацию Все статьи