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

Важно знать

  • Дефолт — конструктор агрегата. Factory вводится только когда конструктор не справляется.
  • Triggers для Factory: валидация требует другого агрегата (нельзя создать Order для деактивированного Customer), сборка из нескольких частей, выбор подкласса по политике.
  • Factory возвращает уже валидный агрегат — со всеми инвариантами + с зарегистрированными начальными событиями (OrderCreated).
  • Factory не загружает агрегаты из репозитория. Если нужен другой агрегат — он приходит параметром, загрузка — в UseCaseHandler.
  • Factory как stateless-объект в core/, либо как статический метод на самом агрегате (Order.createFor(customer, items)) — оба варианта годятся.
  • R-FAC-X1 Factory ради Factory — антипаттерн. Если new Order(id, customerId, items) справляется — лишний слой не нужен.
  • Factory ≠ Builder. Builder для удобства конструирования (fluent API); Factory — для бизнес-правил создания, без которых агрегат не может быть валиден.

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

Когда вводим

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

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

public final class OrderFactory {

    public Order createFor(Customer customer, List<OrderItem> items) {
        if (!customer.isActive()) {
            throw new CustomerNotActiveException(customer.id());
        }
        if (customer.creditLimit().isLessThan(totalOf(items))) {
            throw new CreditLimitExceededException(customer.id(), customer.creditLimit());
        }
        Order order = new Order(OrderId.generate(), customer.id(), items);
        return order;
    }
}

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

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

public final class PaymentFactory {

    public Payment createFor(Order order, PaymentMethod method) {
        return switch (method.type()) {
            case CARD -> new CardPayment(PaymentId.generate(), order.id(), method.cardToken(), order.total());
            case SBP  -> new SbpPayment(PaymentId.generate(), order.id(), method.phone(), order.total());
            case CASH -> new CashPayment(PaymentId.generate(), order.id(), order.total());
        };
    }
}

Полиморфные агрегаты с общим супертипом — редкий случай в нашем стиле, но если возникает (например, разные типы платежей), Factory скрывает выбор подкласса.

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

public final class InvoiceFactory {

    public Invoice createFromOrders(Customer customer, List<Order> orders, BillingPeriod period) {
        List<InvoiceLine> lines = orders.stream()
            .filter(o -> o.status() == OrderStatus.DELIVERED)
            .map(o -> new InvoiceLine(o.id(), o.total(), o.deliveredAt()))
            .toList();
        if (lines.isEmpty()) {
            throw new NoOrdersToInvoiceException(customer.id(), period);
        }
        return new Invoice(InvoiceId.generate(), customer.id(), period, lines);
    }
}

Invoice собирается из нескольких Order-ов с фильтрацией и трансформацией. Если бы это пытался делать конструктор Invoice, ему пришлось бы принимать List<Order> — а это нарушение R-AGG-5 (ссылки между агрегатами по ID).

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

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

public final class OrderFactory {

    public Order createFor(Customer customer, List<OrderItem> items) {
        if (!customer.isActive()) {
            throw new CustomerNotActiveException(customer.id());
        }
        OrderId id = OrderId.generate();
        Order order = new Order(id, customer.id(), items);
        // конструктор сам регистрирует OrderCreated:
        // registerEvent(new OrderCreated(id, customer.id(), order.total()));
        return order;
    }
}

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

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

@Component
@RequiredArgsConstructor
class CreateOrderHandler implements UseCaseHandler<CreateOrder, OrderId> {

    private final CustomerRepository customerRepository;
    private final OrderRepository orderRepository;
    private final OrderFactory orderFactory;

    @Override
    @Transactional
    public OrderId handle(CreateOrder command) {
        Customer customer = customerRepository.findById(command.customerId(), SelectMode.NO_LOCK)
            .orElseThrow(() -> new CustomerNotFoundException(command.customerId()));
        Order order = orderFactory.createFor(customer, command.items());
        orderRepository.save(order);   // публикует OrderCreated через outbox
        return order.id();
    }
}

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

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

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

// ПЛОХО — Factory без бизнес-значения, просто обёртка над new
public final class OrderFactory {
    public Order create(OrderId id, CustomerId customerId, List<OrderItem> items) {
        return new Order(id, customerId, items);
    }
}

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

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

  • «Чтобы скрыть new». new — это нормальная Java, скрывать его без причины не нужно.
  • «Чтобы единообразие — у нас везде Factory». Cargo-cult. Единообразие хорошо там, где есть смысл; здесь его нет.
  • «Чтобы можно было мокать». Если в тесте нужен другой Order, тест строит его прямым new или builder-ом, не через mock factory.

Дефолт — конструктор. Factory появляется на основании конкретного триггера из R-FAC-1.

Factory в core/, не в адаптере

R-FAC-1 подразумевает: Factory — это часть домена. Живёт в core/<bc>/domain/factory/, не зависит от Spring/jOOQ/HTTP. В нашем стиле он stateless, тестируется unit-тестом, инжектится в UseCaseHandler напрямую как @Component-бин (через @RequiredArgsConstructor):

// core/order/domain/factory/OrderFactory.java
@Component
public final class OrderFactory {
    public Order createFor(Customer customer, List<OrderItem> items) { /* ... */ }
}

@Component в core/ — пограничный случай. В строгом Hexagonal (Уровень 3) core/ без Spring; Factory тогда — pure-Java класс, конструируется руками в bootstrap/ или объявляется через @Bean в @Configuration-классе. На Уровне 2 — @Component в core/ допускается (см. Hexagonal: когда переходить).

Альтернатива — статический метод на самом агрегате:

public final class Order extends AggregateRoot<OrderId> {

    public static Order createFor(Customer customer, List<OrderItem> items) {
        if (!customer.isActive()) {
            throw new CustomerNotActiveException(customer.id());
        }
        return new Order(OrderId.generate(), customer.id(), items);
    }
}

Это короче для простых случаев. Когда правила усложняются и появляются зависимости (например, PromoCodeService для расчёта скидки на момент создания) — выносим в отдельный класс OrderFactory.

Factory ≠ Builder

Builder — паттерн конструирования (fluent API, опциональные параметры, читаемая ленточная сборка). Factory — паттерн бизнес-правил создания. Они решают разные задачи.

// Builder — про удобство
Order order = Order.builder()
    .id(OrderId.generate())
    .customerId(customerId)
    .item("p-1", 2, money(100))
    .item("p-2", 1, money(50))
    .build();

// Factory — про правила
Order order = orderFactory.createFor(customer, items);

Builder уместен в тестах (TestObjectGenerator) и иногда внутри domain-кода — когда у агрегата много опциональных полей и читаемость от builder-а выигрывает. Builder не заменяет Factory: правило «нельзя создать Order для деактивированного Customer» нельзя положить в Order.builder().build() без зависимости от Customer.

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

АнтипаттернПравилоЧто взамен
Factory ради Factory (просто обёртка над new)R-FAC-X1new Order(...) напрямую
Factory загружает агрегаты из репозиторияR-FAC-1Загрузка в UseCaseHandler, Factory принимает агрегаты параметрами
Factory не регистрирует начальные событияR-FAC-2События регистрируются в конструкторе агрегата (см. R-EVT-X3)
Builder вместо Factory, когда есть правилаBuilder для удобства, Factory для бизнес-правил создания

Куда дальше

  • DDD Tactical → раздел 7. Factory — нормативные формулировки R-FAC-*.
  • Aggregate Root — registerEvent для начального события в конструкторе.
  • Domain Event — OrderCreated и публикация после save.
  • Domain Service — соседний паттерн «логика про два агрегата», часто путают с Factory.
  • Hexagonal: когда переходить — про @Component в core/ на разных уровнях зрелости.