Опирается на правила:
R-FAC-1…R-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-X1Factory ради 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-X1 | new Order(...) или Order.create(...) напрямую |
Factory загружает агрегаты из репозитория (await this.orderRepo.byId(...)) | R-FAC-1 | Загрузка в UseCaseHandler, Factory принимает агрегаты параметрами |
Factory не регистрирует начальные события — агрегат возвращается без OrderCreated | R-FAC-2 | События регистрируются в static create() агрегата (см. R-EVT-X3) |
@Injectable() и NestJS-декораторы внутри core/factory/ | R-MOD-2 | Plain 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-*и полный индекс правил.