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

Чистая архитектура и DDD в Node.js: практическое руководство для production

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

Чистая архитектура и DDD в Node.js: практическое руководство для production

Большинство Node.js проектов начинаются чисто и деградируют в «controllers, которые делают всё». Полгода разработки — бизнес-логика в route-хендлерах, Prisma везде, тесты либо отсутствуют либо требуют базу данных. Решение давно известно — нужно только применить правильно.


Ключевая идея: Dependency Rule

Зависимости всегда направлены внутрь — к домену:

Presentation → Application → Domain
Infrastructure → Domain

Domain — ничего не импортирует.
Application — импортирует только Domain.
Infrastructure/Presentation — импортируют всё нужное.


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

src/
├── domain/
│   ├── entities/
│   │   ├── Order.ts        ← бизнес-логика
│   │   └── Product.ts
│   ├── repositories/
│   │   ├── IOrderRepository.ts    ← контракты
│   │   └── IProductRepository.ts
│   ├── services/
│   │   └── IPricingService.ts
│   └── value-objects/
│       ├── Money.ts        ← ценностные объекты
│       └── Email.ts
│
├── application/
│   └── orders/
│       ├── CreateOrder.ts  ← use case
│       ├── CancelOrder.ts
│       └── GetUserOrders.ts
│
├── infrastructure/
│   ├── database/
│   │   └── prisma/
│   │       └── PrismaOrderRepository.ts
│   ├── payment/
│   │   └── KaspiPayService.ts
│   └── container.ts
│
└── presentation/
    └── http/
        ├── routes/
        └── controllers/

Domain: Value Objects

Value Objects — неизменяемые объекты без идентичности. Логика «что значит быть денежной суммой» — здесь:

// src/domain/value-objects/Money.ts
export class Money {
  private constructor(
    private readonly _amount: number,
    private readonly _currency: 'KZT' | 'USD' | 'EUR',
  ) {
    if (_amount < 0) throw new Error('Amount cannot be negative');
    if (!Number.isFinite(_amount)) throw new Error('Amount must be finite');
  }

  static of(amount: number, currency: 'KZT' | 'USD' | 'EUR'): Money {
    return new Money(Math.round(amount * 100) / 100, currency);
  }

  static zero(currency: 'KZT' | 'USD' | 'EUR'): Money {
    return new Money(0, currency);
  }

  get amount(): number { return this._amount; }
  get currency(): string { return this._currency; }

  add(other: Money): Money {
    if (other._currency !== this._currency) {
      throw new Error(`Currency mismatch: ${this._currency} vs ${other._currency}`);
    }
    return new Money(this._amount + other._amount, this._currency);
  }

  multiply(factor: number): Money {
    return new Money(this._amount * factor, this._currency);
  }

  equals(other: Money): boolean {
    return this._amount === other._amount && this._currency === other._currency;
  }

  toString(): string {
    return `${this._amount.toFixed(2)} ${this._currency}`;
  }
}

// Использование:
const price = Money.of(4500, 'KZT');
const quantity = 3;
const total = price.multiply(quantity); // 13500.00 KZT

Domain: Entity с бизнес-логикой

// src/domain/entities/Order.ts
import { Money } from '../value-objects/Money';

export type OrderStatus = 'pending' | 'paid' | 'processing' | 'shipped' | 'delivered' | 'cancelled';

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

export class Order {
  private _items: OrderItem[];
  private _status: OrderStatus;
  private readonly _events: DomainEvent[] = [];

  constructor(
    readonly id: string,
    readonly userId: string,
    readonly storeId: string,
    items: OrderItem[],
    status: OrderStatus = 'pending',
  ) {
    if (items.length === 0) throw new Error('Order must have at least one item');
    this._items = [...items];
    this._status = status;
  }

  get status(): OrderStatus { return this._status; }
  get items(): Readonly<OrderItem[]> { return this._items; }
  get events(): Readonly<DomainEvent[]> { return this._events; }

  get subtotal(): Money {
    return this._items.reduce(
      (sum, item) => sum.add(item.unitPrice.multiply(item.quantity)),
      Money.zero('KZT')
    );
  }

  markPaid(paymentReference: string): void {
    if (this._status !== 'pending') {
      throw new Error(`Cannot pay order with status: ${this._status}`);
    }
    this._status = 'paid';
    this._events.push({
      type: 'OrderPaid',
      orderId: this.id,
      paymentReference,
      amount: this.subtotal,
      occurredAt: new Date(),
    });
  }

  cancel(reason: string): void {
    const cancellableStatuses: OrderStatus[] = ['pending', 'paid'];
    if (!cancellableStatuses.includes(this._status)) {
      throw new Error(`Cannot cancel order with status: ${this._status}`);
    }
    this._status = 'cancelled';
    this._events.push({
      type: 'OrderCancelled',
      orderId: this.id,
      reason,
      occurredAt: new Date(),
    });
  }

  clearEvents(): void {
    this._events.length = 0;
  }
}

interface DomainEvent {
  type: string;
  orderId: string;
  occurredAt: Date;
  [key: string]: unknown;
}

Application: Use Case

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

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

interface CreateOrderOutput {
  orderId: string;
  subtotal: string;
  itemCount: number;
}

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

  async execute(input: CreateOrderInput): Promise<CreateOrderOutput> {
    // Validate & build order items
    const orderItems: OrderItem[] = [];
    for (const input_item of input.items) {
      const product = await this.products.findById(input_item.productId);
      
      if (!product) {
        throw new Error(`Продукт не найден: ${input_item.productId}`);
      }
      if (!product.isAvailable) {
        throw new Error(`Продукт недоступен: ${product.name}`);
      }
      if (product.stock < input_item.quantity) {
        throw new Error(`Недостаточно товара "${product.name}": доступно ${product.stock}, запрошено ${input_item.quantity}`);
      }

      orderItems.push({
        productId: product.id,
        name: product.name,
        unitPrice: Money.of(product.priceKzt, 'KZT'),
        quantity: input_item.quantity,
      });
    }

    // Create domain entity — business rules enforced here
    const order = new Order(
      randomUUID(),
      input.userId,
      input.storeId,
      orderItems,
    );

    // Persist
    await this.orders.save(order);

    // Publish domain events
    for (const event of order.events) {
      await this.eventBus.publish(event);
    }
    order.clearEvents();

    return {
      orderId: order.id,
      subtotal: order.subtotal.toString(),
      itemCount: order.items.length,
    };
  }
}

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

describe('CreateOrderUseCase', () => {
  it('создаёт заказ и считает сумму в тенге', async () => {
    const mockOrders = { save: jest.fn(), findById: jest.fn(), findByUserId: jest.fn(), update: jest.fn() };
    const mockProducts = {
      findById: jest.fn().mockResolvedValue({
        id: 'p1', name: 'Ноутбук', priceKzt: 450000, stock: 5, isAvailable: true,
      }),
    };
    const mockEventBus = { publish: jest.fn() };

    const useCase = new CreateOrderUseCase(mockOrders, mockProducts, mockEventBus);
    const result = await useCase.execute({
      userId: 'u1',
      storeId: 's1',
      items: [{ productId: 'p1', quantity: 2 }],
    });

    expect(result.subtotal).toBe('900000.00 KZT');
    expect(mockOrders.save).toHaveBeenCalledTimes(1);
  });
});

Infrastructure: Prisma Repository

// src/infrastructure/database/prisma/PrismaOrderRepository.ts
import { PrismaClient } from '@prisma/client';
import { Order, OrderItem } from '../../../domain/entities/Order';
import { Money } from '../../../domain/value-objects/Money';
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,
        storeId: order.storeId,
        status: order.status,
        subtotalKzt: order.subtotal.amount,
        items: {
          createMany: {
            data: order.items.map(i => ({
              productId: i.productId,
              name: i.name,
              unitPriceKzt: i.unitPrice.amount,
              quantity: i.quantity,
            })),
          },
        },
      },
    });
  }

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

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

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

  private toDomain(record: any): Order {
    const items: OrderItem[] = record.items.map((i: any) => ({
      productId: i.productId,
      name: i.name,
      unitPrice: Money.of(i.unitPriceKzt, 'KZT'),
      quantity: i.quantity,
    }));
    return new Order(record.id, record.userId, record.storeId, items, record.status);
  }
}

Когда применять DDD + Clean Architecture

Эта архитектура оправдана при:

  • Сложных бизнес-правилах (e-commerce, fintech, logistics)
  • Команде из 2+ разработчиков
  • Планируемой долгосрочной поддержке
  • Необходимости тестировать бизнес-логику быстро и надёжно

Избыточно для:

  • Простых CRUD-приложений
  • Прототипов и MVP
  • Команды из одного разработчика с ограниченным временем

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

Смотрите также: tRPC + Zod fullstack типобезопасность, Event-driven архитектура с Kafka

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

OWASP Top 10 2025: безопасность веб-приложений для казахстанского разработчикаaunimeda
Разработка

OWASP Top 10 2025: безопасность веб-приложений для казахстанского разработчика

OWASP Top 10 — это стандарт критических рисков безопасности. SQL-инъекции, сломанный контроль доступа, SSRF — каждый пункт с реальной атакой на ваш Node.js/Next.js код и конкретным исправлением. Актуально для проектов на казахстанском рынке.

Node.js vs Bun vs Deno 2026: бенчмарки и выбор runtime для продакшнaunimeda
Разработка

Node.js vs Bun vs Deno 2026: бенчмарки и выбор runtime для продакшн

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

Supabase vs Firebase 2026: сравнение для казахстанских стартапов и командaunimeda
Разработка

Supabase vs Firebase 2026: сравнение для казахстанских стартапов и команд

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

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

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

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