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

Чистая архитектура в Node.js: практическое руководство без академизма

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

Чистая архитектура в Node.js: практическое руководство без академизма

Каждые несколько месяцев появляется статья «Чистая архитектура в Node.js» с 15 папками, 6 слоями абстракции и диаграммой. Потом пытаешься внедрить — и тратишь больше времени на управление косвенностью, чем на написание фич.

Это руководство — о том, что реально работает в production Node.js TypeScript проектах. Какие части Clean Architecture стоит сохранить, какие пропустить, и конкретный рабочий код.


Ключевой принцип, который стоит сохранить

В Clean Architecture Боба Мартина есть одна действительно важная идея:

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

Код обработки заказов не должен напрямую импортировать Prisma, Express или Stripe. Почему:

  • Тестирование: бизнес-логику можно тестировать без базы данных
  • Замена: переход с Prisma на Drizzle не затрагивает бизнес-логику
  • Ясность: бизнес-правила в одном месте, не размазаны по Express-хендлерам

Всё остальное — слои, диаграммы, именование — вторично.


Структура папок, которая работает

src/
├── domain/              # Сущности и бизнес-правила
│   ├── entities/
│   │   ├── Order.ts
│   │   └── User.ts
│   └── repositories/    # Интерфейсы (контракты)
│       ├── IOrderRepository.ts
│       └── IUserRepository.ts
│
├── application/         # Use cases (что может делать приложение)
│   ├── orders/
│   │   ├── CreateOrder.ts
│   │   ├── GetUserOrders.ts
│   │   └── CancelOrder.ts
│   └── users/
│       └── RegisterUser.ts
│
├── infrastructure/      # Конкретные реализации
│   ├── database/
│   │   └── prisma/
│   │       ├── PrismaOrderRepository.ts
│   │       └── PrismaUserRepository.ts
│   ├── email/
│   │   └── SendgridEmailService.ts
│   └── payment/
│       └── StripePaymentService.ts
│
└── presentation/        # HTTP, очереди — код фреймворка
    ├── http/
    │   ├── routes/
    │   └── controllers/
    └── app.ts

Три ключевых правила:

  • domain/ ничего не импортирует из других слоёв
  • application/ импортирует только из domain/
  • infrastructure/ и presentation/ могут импортировать откуда угодно

Доменные сущности

Сущности содержат бизнес-логику, не только данные:

// src/domain/entities/Order.ts
export type OrderStatus = 'pending' | 'paid' | 'shipped' | 'cancelled';

export class Order {
  readonly id: string;
  readonly userId: string;
  private _status: OrderStatus;
  private _items: OrderItem[];
  private _total: number;

  constructor(props: {
    id: string;
    userId: string;
    items: OrderItem[];
    status?: OrderStatus;
  }) {
    this.id = props.id;
    this.userId = props.userId;
    this._items = props.items;
    this._status = props.status ?? 'pending';
    this._total = this.calculateTotal();
  }

  get status(): OrderStatus { return this._status; }
  get items(): Readonly<OrderItem[]> { return this._items; }
  get total(): number { return this._total; }

  // Бизнес-правило: можно отменить только pending заказы
  cancel(): void {
    if (this._status !== 'pending') {
      throw new Error(`Нельзя отменить заказ со статусом: ${this._status}`);
    }
    this._status = 'cancelled';
  }

  // Бизнес-правило: доставка только после оплаты
  markShipped(): void {
    if (this._status !== 'paid') {
      throw new Error(`Заказ должен быть оплачен перед отправкой`);
    }
    this._status = 'shipped';
  }

  private calculateTotal(): number {
    return this._items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  }
}

Обратите внимание: нет импортов библиотек. Чистый TypeScript. Тестируется без базы данных.


Интерфейс репозитория (инверсия зависимостей)

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

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

Это контракт. Слой application зависит от этого интерфейса — не от Prisma, не от конкретной базы данных.


Use Cases (слой application)

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

interface CreateOrderInput {
  userId: string;
  items: Array<{ productId: string; quantity: number }>;
}

export class CreateOrderUseCase {
  constructor(
    private readonly orderRepository: IOrderRepository,
    private readonly productRepository: IProductRepository,
    private readonly eventPublisher: IEventPublisher,
  ) {}

  async execute(input: CreateOrderInput) {
    // 1. Проверяем что продукты существуют и есть в наличии
    const orderItems: OrderItem[] = [];
    for (const item of input.items) {
      const product = await this.productRepository.findById(item.productId);
      if (!product) throw new Error(`Продукт ${item.productId} не найден`);
      if (product.stock < item.quantity) {
        throw new Error(`Недостаточно товара: ${item.productId}`);
      }
      orderItems.push({
        productId: product.id,
        name: product.name,
        price: product.price,
        quantity: item.quantity,
      });
    }

    // 2. Создаём сущность заказа (бизнес-логика здесь)
    const order = new Order({
      id: randomUUID(),
      userId: input.userId,
      items: orderItems,
    });

    // 3. Сохраняем
    await this.orderRepository.save(order);

    // 4. Публикуем доменное событие
    await this.eventPublisher.publish('order.created', {
      orderId: order.id,
      userId: order.userId,
      total: order.total,
    });

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

Use case тестируется полностью в изоляции — мок-репозитории, без базы данных:

describe('CreateOrderUseCase', () => {
  it('должен создать заказ с правильной суммой', async () => {
    const mockOrderRepo = {
      save: jest.fn(),
      findById: jest.fn(),
      findByUserId: jest.fn(),
      update: jest.fn(),
    };
    
    const mockProductRepo = {
      findById: jest.fn().mockResolvedValue({
        id: 'prod-1',
        name: 'Виджет',
        price: 1500,
        stock: 10,
      }),
    };
    
    const mockEventPublisher = { publish: jest.fn() };

    const useCase = new CreateOrderUseCase(
      mockOrderRepo,
      mockProductRepo,
      mockEventPublisher,
    );

    const result = await useCase.execute({
      userId: 'user-1',
      items: [{ productId: 'prod-1', quantity: 2 }],
    });

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

Реализация инфраструктуры

// src/infrastructure/database/prisma/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 readonly prisma: PrismaClient) {}

  async findById(id: string): Promise<Order | null> {
    const record = await this.prisma.order.findUnique({
      where: { id },
      include: { items: true },
    });
    if (!record) return null;
    return this.toDomain(record);
  }

  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(item => ({
              productId: item.productId,
              name: item.name,
              price: item.price,
              quantity: item.quantity,
            })),
          },
        },
      },
    });
  }

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

  async findByUserId(userId: string, options = {}): Promise<Order[]> {
    const records = await this.prisma.order.findMany({
      where: { userId },
      include: { items: true },
      take: options.limit ?? 20,
      skip: options.offset ?? 0,
      orderBy: { createdAt: 'desc' },
    });
    return records.map(r => this.toDomain(r));
  }

  private toDomain(record: any): Order {
    return new Order({
      id: record.id,
      userId: record.userId,
      status: record.status,
      items: record.items.map((i: any) => ({
        productId: i.productId,
        name: i.name,
        price: i.price,
        quantity: i.quantity,
      })),
    });
  }
}

Dependency Injection без фреймворка

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

const prisma = new PrismaClient();
const orderRepository = new PrismaOrderRepository(prisma);
const productRepository = new PrismaProductRepository(prisma);
const eventPublisher = new EventEmitterPublisher();

export const useCases = {
  createOrder: new CreateOrderUseCase(orderRepository, productRepository, eventPublisher),
  cancelOrder: new CancelOrderUseCase(orderRepository, eventPublisher),
};

Фреймворки InversifyJS или tsyringe добавят декораторы, но для большинства приложений ручная composition root проще и понятнее при дебаге.


HTTP контроллер (слой presentation)

// src/presentation/http/controllers/OrderController.ts
import { Request, Response } from 'express';
import { useCases } from '../../../infrastructure/container';
import { z } from 'zod';

const CreateOrderSchema = z.object({
  items: z.array(z.object({
    productId: z.string().uuid(),
    quantity: z.number().int().min(1),
  })).min(1),
});

export class OrderController {
  async create(req: Request, res: Response): Promise<void> {
    const parsed = CreateOrderSchema.safeParse(req.body);
    if (!parsed.success) {
      res.status(400).json({ error: parsed.error.flatten() });
      return;
    }

    try {
      const result = await useCases.createOrder.execute({
        userId: req.user.id,
        items: parsed.data.items,
      });
      res.status(201).json(result);
    } catch (err: any) {
      if (err.message.includes('Недостаточно товара') || err.message.includes('не найден')) {
        res.status(422).json({ error: err.message });
        return;
      }
      throw err;
    }
  }
}

Контроллер знает про HTTP (статусы, request/response). Ничего не знает про базы данных.


Когда НЕ нужна чистая архитектура

Этот паттерн добавляет реальный overhead. Пропустите его если:

  • CRUD без бизнес-логики — простой POST /users с INSERT не нуждается в use case
  • Прототипы и MVP — сначала запустите, рефакторинг когда бизнес-логика реально усложнится
  • Маленькие API (<20 эндпоинтов) — церемония часто превышает пользу
  • Один разработчик — паттерн окупается когда команды работают параллельно

Добавляйте слои инкрементально. Начните с хорошо организованного Express/Fastify приложения. Выносите use cases когда нужно тестировать бизнес-логику или переиспользовать её в нескольких контекстах (HTTP + очереди).


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

Смотрите также: tRPC + Zod типобезопасные API, Supabase vs Firebase 2026

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

Node.js vs Bun vs Deno 2026: какой JavaScript runtime выбратьaunimeda
Разработка

Node.js vs Bun vs Deno 2026: какой JavaScript runtime выбрать

Bun 1.x стабилен в production. Deno 2.0 поддерживает npm. Node.js 22 запускает TypeScript нативно. Реальные бенчмарки, сравнение экосистем и конкретные рекомендации — для новых и существующих проектов.

State of JavaScript 2026: что изменилось и куда движется экосистемаaunimeda
Разработка

State of JavaScript 2026: что изменилось и куда движется экосистема

Vite обошёл webpack. TypeScript — дефолт для новых проектов. React сохраняет доминирование, но Signal-based фреймворки растут. AI-assisted coding меняет что значит 'написать код'. Честный разбор состояния JavaScript-экосистемы в 2026.

TypeScript продвинутые типы: conditional types, infer и mapped types на реальных примерахaunimeda
Разработка

TypeScript продвинутые типы: conditional types, infer и mapped types на реальных примерах

Система типов TypeScript — это полноценный язык программирования на уровне типов. Разбираем conditional types, infer, mapped types и template literal types на реальных задачах, которые возникают в production коде.

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

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

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