Опирается на правила: R-FAC-1R-FAC-2 и R-FAC-X1 из DDD Tactical Style Guide → раздел 7. Factory.

Важно знать

  • Дефолт — конструктор агрегата. Factory вводится только когда конструктор не справляется.
  • Triggers для Factory: валидация требует другого агрегата, сборка из нескольких частей, выбор подкласса по политике.
  • Factory возвращает уже валидный агрегат — со всеми инвариантами и зарегистрированными начальными событиями.
  • Factory не загружает агрегаты из репозитория. Если нужен другой агрегат — он приходит параметром, загрузка остаётся в UseCaseHandler.
  • В Node два допустимых варианта: статический метод на самом агрегате (Order.create(...)) или отдельный класс в core/<bc>/factory/ без NestJS-декораторов.
  • R-FAC-X1 Factory ради Factory — антипаттерн. Если new Order(id, customerId) справляется — лишний слой не нужен.
  • Factory ≠ Builder. Builder — про удобство конструирования (fluent API); Factory — про бизнес-правила создания, без которых агрегат не может быть валиден.
  • В Node нет ddd-building-blocks — базовые классы пишутся руками (AggregateRoot<ID>, DomainEvent); Factory опирается на тот же building-blocks.ts из core/shared/.

Factory — паттерн, который вводится узко и поздно. На старте сервиса большинство агрегатов создаются обычным конструктором: new Order(OrderId(ids.next()), customerId). Factory нужен, когда «создание» — это самостоятельная бизнес-операция со своими правилами и зависимостями. Если правил нет — Factory лишний слой.

Когда вводим

R-FAC-1: один из триггеров должен сработать.

Триггер 1 — валидация требует другого агрегата

Конструктор Order не может проверить customer.isActive() и customer.creditLimit() — у него нет ссылки на Customer (R-AGG-5). Factory принимает Customer параметром, проверяет правило, передаёт в Order только CustomerId.

// core/order/factory/order.factory.ts
import { OrderId } from '../value-object/ids';
import { Money } from '../value-object/money';
import { Order } from '../aggregate/order';
import { Customer } from '../../customer/aggregate/customer';
import { OrderItem } from '../value-object/order-item';
import { DomainError } from '../../shared/domain-error';

export class OrderFactory {
  createFor(customer: Customer, items: OrderItem[], now: Date, ids: IdGenerator): Order {
    if (!customer.isActive()) {
      throw new DomainError(`Customer ${customer.id} не активен`);
    }
    const total = items.reduce((acc, i) => acc.add(i.subtotal()), Money.zero('RUB'));
    if (customer.creditLimit().amount.lt(total.amount)) {
      throw new DomainError(`Кредитный лимит превышен: лимит ${customer.creditLimit().amount}, заказ ${total.amount}`);
    }
    return Order.create(OrderId(ids.next()), customer.id, items, now);
  }
}

Order.create — статический метод самого агрегата, он регистрирует OrderCreated и возвращает готовый объект:

// core/order/aggregate/order.ts
import { AggregateRoot } from '../../shared/building-blocks';
import { OrderCreated } from '../event/order-created';
import { OrderStatus } from '../value-object/order-status';
import { OrderId, CustomerId } from '../value-object/ids';
import { OrderItem } from '../value-object/order-item';
import { Money } from '../value-object/money';
import { DomainError } from '../../shared/domain-error';
import { uuidv7 } from 'uuidv7';

export class Order extends AggregateRoot<OrderId> {
  private status: OrderStatus = OrderStatus.NEW;
  private readonly orderItems: OrderItem[];

  private constructor(id: OrderId, readonly customerId: CustomerId, items: OrderItem[]) {
    super(id);
    this.orderItems = [...items];
  }

  static create(id: OrderId, customerId: CustomerId, items: OrderItem[], now: Date): Order {
    if (items.length === 0) throw new DomainError('Нельзя создать пустой заказ');
    const order = new Order(id, customerId, items);
    order.registerEvent(new OrderCreated(uuidv7(), now, id, customerId, order.total()));
    return order;
  }

  get items(): ReadonlyArray<OrderItem> {
    return [...this.orderItems];
  }

  total(): Money {
    return this.orderItems.reduce((acc, i) => acc.add(i.subtotal()), Money.zero('RUB'));
  }

  confirm(now: Date): void {
    if (this.status !== OrderStatus.NEW) throw new DomainError('Заказ уже обработан');
    this.status = OrderStatus.CONFIRMED;
    this.registerEvent(new OrderConfirmed(uuidv7(), now, this.id, this.customerId, this.total()));
  }
}

Конструктор private — снаружи создать Order можно только через Order.create(...). Это принудительный контракт: не пройдя Factory и статический метод, получить невалидный агрегат невозможно.

Триггер 2 — выбор подкласса по политике

В нашем стиле полиморфные агрегаты встречаются редко, но для платёжных методов Сбера паттерн уместен:

// core/payment/factory/payment.factory.ts
import { PaymentId } from '../value-object/ids';
import { Payment } from '../aggregate/payment';
import { CardPayment } from '../aggregate/card-payment';
import { SbpPayment } from '../aggregate/sbp-payment';
import { PaymentMethod, PaymentMethodType } from '../value-object/payment-method';
import { Order } from '../../order/aggregate/order';
import { DomainError } from '../../shared/domain-error';
import { uuidv7 } from 'uuidv7';

export class PaymentFactory {
  createFor(order: Order, method: PaymentMethod, now: Date, ids: IdGenerator): Payment {
    const paymentId = PaymentId(ids.next());
    switch (method.type) {
      case PaymentMethodType.CARD:
        return CardPayment.create(paymentId, order.id, method.cardToken, order.total(), now);
      case PaymentMethodType.SBP:
        return SbpPayment.create(paymentId, order.id, method.phone, order.total(), now);
      default:
        throw new DomainError(`Неизвестный тип оплаты: ${method.type}`);
    }
  }
}

Factory скрывает выбор подкласса. Вызывающий код работает с абстрактным Payment, не зная о CardPayment/SbpPayment.

Триггер 3 — сборка из нескольких частей

Invoice агрегирует несколько Order-ов с фильтрацией. Конструктор не может принимать Order[] — это нарушение R-AGG-5 (ссылки между агрегатами только по ID). Factory принимает агрегаты параметрами, строит InvoiceLine[] и передаёт их в конструктор:

// core/billing/factory/invoice.factory.ts
import { Invoice } from '../aggregate/invoice';
import { InvoiceLine } from '../value-object/invoice-line';
import { InvoiceId } from '../value-object/ids';
import { BillingPeriod } from '../value-object/billing-period';
import { Order } from '../../order/aggregate/order';
import { Customer } from '../../customer/aggregate/customer';
import { OrderStatus } from '../../order/value-object/order-status';
import { DomainError } from '../../shared/domain-error';

export class InvoiceFactory {
  createFromOrders(customer: Customer, orders: Order[], period: BillingPeriod, now: Date, ids: IdGenerator): Invoice {
    const lines: InvoiceLine[] = orders
      .filter(o => o.status === OrderStatus.DELIVERED)
      .map(o => new InvoiceLine(o.id, o.total(), o.deliveredAt));

    if (lines.length === 0) {
      throw new DomainError(`Нет доставленных заказов за период ${period.toString()} для клиента ${customer.id}`);
    }

    return Invoice.create(InvoiceId(ids.next()), customer.id, period, lines, now);
  }
}

Invoice.create принимает InvoiceLine[] (Value Objects с OrderId внутри) — ссылки на агрегаты Order до Invoice не доходят.

Возвращает валидный агрегат с начальными событиями

R-FAC-2: на выходе из Factory — агрегат, готовый к save. Все инварианты проверены, начальные события зарегистрированы.

OrderCreated регистрируется внутри статического метода агрегата (R-EVT-X3 — события только в корне). Factory не регистрирует события сам — он создаёт агрегат, который «представляется миру» через своё начальное событие.

// core/order/event/order-created.ts
import { DomainEvent } from '../../shared/building-blocks';
import { OrderId, CustomerId } from '../value-object/ids';
import { Money } from '../value-object/money';

export class OrderCreated extends DomainEvent {
  constructor(
    eventId: string,
    occurredAt: Date,
    orderId: OrderId,
    readonly customerId: CustomerId,
    readonly total: Money,
  ) {
    super(eventId, occurredAt, orderId);
    Object.freeze(this);
  }
}

Сценарий вызова в UseCaseHandler:

// core/order/usecases/create-order.handler.ts
import { Injectable, Inject } from '@nestjs/common';
import { ORDER_REPOSITORY, OrderRepository } from '../port/order-repository';
import { CUSTOMER_REPOSITORY, CustomerRepository } from '../../customer/port/customer-repository';
import { OrderFactory } from '../factory/order.factory';
import { CreateOrder } from './create-order.command';
import { OrderId } from '../value-object/ids';
import { DomainError } from '../../shared/domain-error';

@Injectable()
export class CreateOrderHandler {
  constructor(
    @Inject(ORDER_REPOSITORY) private readonly orderRepository: OrderRepository,
    @Inject(CUSTOMER_REPOSITORY) private readonly customerRepository: CustomerRepository,
    private readonly orderFactory: OrderFactory,
    private readonly ids: IdGenerator,
  ) {}

  async handle(command: CreateOrder): Promise<OrderId> {
    const customer = await this.customerRepository.byId(command.customerId);
    if (!customer) throw new DomainError(`Клиент ${command.customerId} не найден`);

    const order = this.orderFactory.createFor(customer, command.items, new Date(), this.ids);
    await this.orderRepository.save(order);   // публикует OrderCreated через Outbox

    return order.id;
  }
}

Handler не «создаёт» order вручную, не «регистрирует событие», не «выставляет статус». Factory отдаёт готовый агрегат, save доставляет событие. Чистый поток.

Размещение в core/ — без NestJS-декораторов

R-FAC-1 подразумевает: Factory — часть домена. Живёт в core/<bc>/factory/, не зависит от NestJS, TypeORM, class-validator.

core/
  order/
    aggregate/
      order.ts            # AggregateRoot, private constructor, static create()
    factory/
      order.factory.ts    # OrderFactory — чистый TypeScript-класс
    event/
      order-created.ts
    value-object/
      ids.ts
      money.ts
      order-item.ts
    port/
      order-repository.ts

Сам OrderFactory — plain TypeScript-класс без @Injectable(). В app/ или order.module.ts он объявляется через @Module.providers:

// app/order.module.ts
import { Module } from '@nestjs/common';
import { OrderFactory } from '../core/order/factory/order.factory';
import { CreateOrderHandler } from '../core/order/usecases/create-order.handler';

@Module({
  providers: [OrderFactory, CreateOrderHandler],
})
export class OrderModule {}

Если Factory нужна внешняя зависимость (например, PromoCodeService для расчёта скидки на момент создания), зависимость инжектируется через конструктор:

export class OrderFactory {
  constructor(private readonly promoCodeService: PromoCodeService) {}

  createFor(customer: Customer, items: OrderItem[], promoCode: string | undefined, now: Date, ids: IdGenerator): Order {
    const discount = promoCode ? this.promoCodeService.calculate(promoCode, items) : Money.zero('RUB');
    // ...
  }
}

PromoCodeService — интерфейс из core/, реализованный в adapters/. Factory не знает о реализации.

Статический метод vs отдельный класс

Когда выбрать статический Order.create(...):

  • правила создания касаются только Order (без других агрегатов);
  • нет зависимостей — только параметры;
  • логика проста и умещается в 5–10 строк.

Когда выносить в отдельный OrderFactory:

  • правила требуют другого агрегата (Customer);
  • появляются зависимости (PromoCodeService, IdGenerator);
  • логика разветвляется (несколько вариантов создания, выбор подкласса).

Оба варианта выполняют R-FAC-1/R-FAC-2. Выбор — по сложности правил, не по принципу.

Как Factory не делает

R-FAC-X1: Factory ради Factory — антипаттерн.

// ПЛОХО — Factory без бизнес-значения, просто обёртка над конструктором
export class OrderFactory {
  create(id: OrderId, customerId: CustomerId): Order {
    return new Order(id, customerId);
  }
}

Это бесполезный слой: метод дублирует конструктор, никакой логики не добавляет, тестировать нечего.

Когда автор склонен к Factory без правил:

  • «Чтобы скрыть new new в TypeScript — нормальная конструкция, скрывать её без причины не нужно.
  • «Чтобы единообразие — у нас везде Factory».» Cargo-cult. Единообразие уместно там, где есть смысл.
  • «Чтобы можно было подменить в тесте». Если в тесте нужен другой Order, тест строит его через Order.create(...) напрямую, не через mock factory.

Factory ≠ Builder

В тестах (core/__tests__/builders/) удобен Builder-паттерн — fluent API для конструирования тестовых данных:

// Тест — Builder для удобства
const order = new OrderBuilder()
  .withCustomer(customerId)
  .withItem('p-1', 2, Money.of('100.00', 'RUB'))
  .withItem('p-2', 1, Money.of('50.00', 'RUB'))
  .build();

// Продакшен — Factory для правил
const order = orderFactory.createFor(customer, items, now, ids);

Builder не заменяет Factory: правило «нельзя создать Order для деактивированного Customer» не положить в OrderBuilder.build() без зависимости от Customer.

Что запрещено

АнтипаттернПравилоЧто взамен
Factory ради Factory (просто обёртка над new / конструктором)R-FAC-X1new Order(...) или Order.create(...) напрямую
Factory загружает агрегаты из репозитория (await this.orderRepo.byId(...))R-FAC-1Загрузка в UseCaseHandler, Factory принимает агрегаты параметрами
Factory не регистрирует начальные события — агрегат возвращается без OrderCreatedR-FAC-2События регистрируются в static create() агрегата (см. R-EVT-X3)
@Injectable() и NestJS-декораторы внутри core/factory/R-MOD-2Plain TypeScript-класс в core/, регистрация через @Module.providers
Builder вместо Factory, когда есть бизнес-правила созданияBuilder — для удобства в тестах; Factory — для правил

Куда дальше

  • node/aggregate-root.md — AggregateRoot<ID>, registerEvent, pullEvents().
  • node/domain-event.md — OrderCreated, иммутабельность, публикация через Outbox.
  • node/domain-service.md — соседний паттерн: логика про два агрегата; часто путают с Factory.
  • node/repository.md — интерфейс + Symbol-токен, публикация событий после save.
  • node/value-object.md — Money, branded types, Object.freeze.
  • node/entity.md — Entity<ID>, identity-equality, equals().
  • node/specification.md — isSatisfiedBy, комбинаторика and/or/not.
  • DDD Tactical Style Guide — нормативные формулировки R-FAC-* и полный индекс правил.