Тактические паттерны DDD отвечают на вопрос «как оформить доменную модель в коде»: Entity, Value Object, Aggregate, Domain Event. Но у этого вопроса есть предшественник, который часто проскакивают: а откуда берётся сама модель? Почему агрегатом стал «Заказ», а не «Корзина»? Почему «адрес» — Value Object, а «клиент» — Entity? Ответы приходят не из паттернов, а из слоя ниже — из системы понятий домена. Её называют онтологией, и это самый недооценённый этап DDD.
Онтология — общее знание: всё, что вообще верно про заказ, независимо от задачи. Доменная модель — срез под конкретную задачу: она берёт часть понятий и добавляет то, чего в общем знании нет, — правило, которое обязана держать. Правило и задаёт границу: «сумма позиций = сумма заказа» держит заказ и позиции вместе, а доставке это правило не нужно — у неё своё.
Онтология: что это и почему она уже есть
Онтология домена — это описание его понятий, их свойств, связей и правил: «заказ состоит из позиций», «у заказа один клиент», «отменённый заказ нельзя оплатить». Никакого кода — только смысл.
Онтология есть у любого проекта, даже если её никто не записывал. Команда всё равно оперирует понятиями — просто у каждого в голове своя версия. DDD-практики — это, по сути, способы сделать систему понятий явной и общей: перемалывание знаний (knowledge crunching) добывает понятия у экспертов, единый язык фиксирует словарь, ограниченные контексты очерчивают границы, внутри которых слова значат ровно одно.
Модель при этом не бывает «истинной» — только подходящей. «Клиент» в контексте продаж и в контексте доставки — два разных понятия, и это не беспорядок, а причина, по которой bounded context вообще существует: он граница системы понятий, а не папка в репозитории.
Как понятия становятся строительными блоками
Почти любая онтология сводится к пяти элементам: объект, отношение, состояние, событие, правило. Объект при этом бывает двух сортов — тот, за которым следят поимённо, и тот, который важен только своим значением, — поэтому в таблице ниже он занимает две строки. А теперь посмотри на тактический словарь DDD: это те же элементы, оформленные в код.
| Элемент онтологии | В доменной модели |
|---|---|
| Объект с идентичностью («этот конкретный заказ») | Entity / Aggregate Root |
| Объект-значение («адрес», «денежная сумма») | Value Object |
| Отношение («заказ состоит из позиций») | связи внутри агрегата или ссылки по id между агрегатами |
| Состояние и переходы («placed → paid», «из cancelled выхода нет») | статусная модель агрегата |
| Событие («заказ оплачен») | Domain Event |
| Правило («сумма позиций равна сумме заказа») | инвариант агрегата |
Как выглядит эта страница
«Выписать простым текстом на странице» — требование, которое проще показать. Вот та самая страница для маленького контекста оформления заказа, целиком:
КОНТЕКСТ: Оформление заказа
Понятия
Заказ — то, что покупатель собрал и подтвердил к оплате.
Живёт от подтверждения до передачи в доставку.
Позиция заказа — товар в заказе: что, сколько, по какой цене.
Цена фиксируется в момент подтверждения.
Покупатель — тот, кто оформляет. Здесь важны только идентификатор
и адрес доставки; всё остальное — контекст «Клиенты».
Промокод — основание для скидки. Проверяется один раз.
Денежная сумма — величина с валютой. Складывается только с той же валютой.
Связи
Заказ состоит из одной или более позиций.
Заказ принадлежит одному покупателю (по идентификатору).
К заказу применён ноль или один промокод.
Состояния заказа
подтверждён → оплачен → передан в доставку
подтверждён → отменён
оплачен → отменён (с возвратом денег)
из отменён и из передан в доставку выхода нет
События
Заказ подтверждён (кто, что, сумма, когда)
Заказ оплачен (заказ, сумма, способ)
Заказ отменён (заказ, причина, кем)
Правила
Сумма заказа равна сумме позиций минус скидка.
Отменить можно до передачи в доставку.
Оплатить можно только подтверждённый заказ, и только на полную сумму.
Позиций в заказе не больше 100.
Промокод применяется один раз и только к неоплаченному заказу.
Открытые вопросы
Что считается «полной суммой» при частичном возврате? (спросить у Марины)
Можно ли менять адрес после оплаты? (сейчас в коде можно, в правилах нет)
Что делает эту страницу работающей, по частям: у каждого понятия одно предложение о смысле, а не список полей; связи сформулированы фразой с количеством («одна или более», «ноль или один») — из них потом получаются и ссылки, и границы; состояния записаны переходами, включая тупики, — это будущая статусная модель; правила пронумерованы и проверяемы — это будущие инварианты и будущие тесты; и есть открытые вопросы с именем того, кто ответит, потому что противоречия — норма, а не позор.
Объём — ровно страница на контекст. Если получилось три, значит, либо контекст надо делить, либо в текст полезли детали реализации.
Чем это не является: не ER и не диаграмма классов
Первая мысль читателя с опытом: «это же ER-диаграмма другими словами». Не она, и подмена здесь ведёт прямиком к «агрегат = таблица», против которой написана эта статья.
| Система понятий | Схема данных (ER) | Диаграмма классов | |
|---|---|---|---|
| Про что | смысл и правила предметной области | как данные лежат | как устроен код |
| Единица | понятие («заказ», «просроченный платёж») | таблица, колонка, связь | класс, поле, метод |
| Правила | записаны словами и главные | выражены ограничениями, где получилось | в методах, если дошли |
| Состояния | переходы как часть смысла | колонка со значением | поле-перечисление |
| Кто читает | бизнес и разработчики вместе | разработчики и администратор базы | разработчики |
| Когда меняется | когда меняется понимание домена | когда меняется хранение | когда меняется код |
Три практических отличия, из которых всё следует:
В системе понятий есть то, чего нет в таблицах. «Просроченный платёж» — полноценное понятие домена с правилами, а в базе это условие в запросе. «Корзина, брошенная без оплаты» — понятие, а в базе колонки нет вовсе. Начав со схемы данных, вы такие понятия не найдёте никогда, потому что у них нет места для хранения.
В таблицах есть то, чего нет в понятиях. Технические колонки, связующие таблицы для связи многие-ко-многим, денормализованные копии, индексные поля. Перенеся их в модель, вы получите классы, которые бизнесу не о чём.
Правила первичны, а не выведены. В схеме данных правило выражается ограничением, если его удалось выразить; в системе понятий правило — сама суть, и именно оно подсказывает границу агрегата. «Сумма заказа равна сумме позиций» в базе — это триггер или ничего; в модели — причина, по которой заказ и позиции меняются вместе.
Проверка, что вы написали систему понятий, а не схему: в тексте нет слов «таблица», «колонка», «внешний ключ», «идентификатор» как поля — и его целиком понимает человек из бизнеса. Если бизнес-эксперт читает и говорит «да, всё так, кроме вот этого» — получилось. Если говорит «я не понимаю, что здесь написано» — получилась схема.
Практический порядок работы получается такой: сначала честно выписать понятия, связи, состояния и правила — простым текстом, на странице; затем эти элементы почти механически раскладываются по паттернам. Граница агрегата перестаёт быть чистым искусством: правило подсказывает кандидата — кластер понятий, который должен меняться вместе, чтобы это правило не нарушалось. «Заказ + позиции» — один агрегат именно потому, что инвариант «сумма позиций = сумма заказа» связывает их в одно целое.
Подсказка — ещё не ответ. Одно и то же требование можно держать по-разному: внутри агрегата, проверкой при чтении или сверкой постфактум с исправлением расхождений. Что выбрать, решают нагрузка и цена рассогласования. В тактических паттернах резерв на складе появляется через секунды после заказа — и это осознанная плата, а не недосмотр.
Когда шаг с онтологией пропускают и «сразу проектируют агрегаты», паттерны вешаются на неосмысленные понятия: агрегаты нарезаются по таблицам, Value Object'ы не находятся (всё — Entity), а инварианты обнаруживаются постфактум — в проде.
Правило «сумма заказа равна сумме позиций» само проводит границу: внутрь агрегата попадает то, что обязано меняться вместе, остальное остаётся ссылкой по идентификатору.
От страницы к коду
«Раскладываются механически» стоит показать на той же странице. Берём её и получаем типы — буквально по строкам.
// Понятие с идентичностью и правилами -> корень агрегата
public class Order {
private final OrderId id;
private final CustomerId customerId; // связь по идентификатору: покупатель в другом контексте
private final List<OrderLine> lines; // связь внутри агрегата: «состоит из»
private Money discount;
private OrderStatus status; // состояние из раздела «Состояния»
private long version;
// Правило «оплатить можно только подтверждённый» — переход состояния, не сеттер
public OrderPaid pay(Money amount, PaymentMethod method) {
if (status != CONFIRMED) {
throw new OrderNotPayable(id, status);
}
if (!amount.equals(total())) { // правило «только на полную сумму»
throw new PartialPaymentNotAllowed(id, total(), amount);
}
status = PAID;
return new OrderPaid(id, amount, method); // событие из раздела «События»
}
// Правило «сумма заказа равна сумме позиций минус скидка» — вычисление, а не поле
public Money total() {
return lines.stream()
.map(OrderLine::amount)
.reduce(Money.zero(currency()), Money::add)
.minus(discount);
}
public void addLine(ProductId product, int quantity, Money price) {
if (status != CONFIRMED) throw new OrderAlreadyProcessed(id, status);
if (lines.size() >= 100) throw new TooManyLines(id); // правило «не больше 100»
lines.add(new OrderLine(product, quantity, price)); // цена зафиксирована здесь
}
}
// Понятие-значение: равенство по значению, неизменяемость, своя арифметика
public record Money(BigDecimal amount, Currency currency) {
public Money add(Money other) {
if (!currency.equals(other.currency)) throw new CurrencyMismatch(currency, other.currency);
return new Money(amount.add(other.amount), currency);
}
}
// Позиция: внутри агрегата, своей жизни снаружи не имеет
public record OrderLine(ProductId product, int quantity, Money price) {
public Money amount() { return price.multiply(quantity); }
}
// Понятие с идентичностью и правилами -> корень агрегата
type Order struct {
id OrderID
customerID CustomerID // связь по идентификатору: покупатель в другом контексте
lines []OrderLine // связь внутри агрегата: «состоит из»
discount Money
status OrderStatus // состояние из раздела «Состояния»
version int64
}
// Правило «оплатить можно только подтверждённый» — переход состояния, не сеттер
func (o *Order) Pay(amount Money, method PaymentMethod) (OrderPaid, error) {
if o.status != Confirmed {
return OrderPaid{}, &OrderNotPayable{ID: o.id, Status: o.status}
}
if amount != o.Total() { // правило «только на полную сумму»
return OrderPaid{}, &PartialPaymentNotAllowed{ID: o.id, Total: o.Total(), Amount: amount}
}
o.status = Paid
return OrderPaid{OrderID: o.id, Amount: amount, Method: method}, nil // событие из раздела «События»
}
// Правило «сумма заказа равна сумме позиций минус скидка» — вычисление, а не поле
func (o *Order) Total() Money {
total := Money{Currency: o.currency()}
for _, l := range o.lines {
total.Amount += l.Amount().Amount // валюта позиций проверена при добавлении
}
return total.Minus(o.discount)
}
func (o *Order) AddLine(product ProductID, quantity int, price Money) error {
if o.status != Confirmed {
return &OrderAlreadyProcessed{ID: o.id, Status: o.status}
}
if len(o.lines) >= 100 { // правило «не больше 100»
return &TooManyLines{ID: o.id}
}
o.lines = append(o.lines, OrderLine{Product: product, Quantity: quantity, Price: price}) // цена зафиксирована здесь
return nil
}
// Понятие-значение: равенство по значению, неизменяемость, своя арифметика
type Money struct {
Amount int64 // в минимальных единицах валюты
Currency Currency
}
func (m Money) Add(other Money) (Money, error) {
if m.Currency != other.Currency {
return Money{}, &CurrencyMismatch{m.Currency, other.Currency}
}
return Money{m.Amount + other.Amount, m.Currency}, nil
}
// Позиция: внутри агрегата, своей жизни снаружи не имеет
type OrderLine struct {
Product ProductID
Quantity int
Price Money
}
func (l OrderLine) Amount() Money { return Money{l.Price.Amount * int64(l.Quantity), l.Price.Currency} }
// Понятие с идентичностью и правилами -> корень агрегата
class Order {
#lines = []; // связь внутри агрегата: «состоит из»
#status; // состояние из раздела «Состояния»
constructor(id, customerId, status, discount) {
this.id = id;
this.customerId = customerId; // связь по идентификатору: покупатель в другом контексте
this.#status = status;
this.discount = discount;
this.version = 0;
}
// Правило «оплатить можно только подтверждённый» — переход состояния, не сеттер
pay(amount, method) {
if (this.#status !== OrderStatus.CONFIRMED) throw new OrderNotPayable(this.id, this.#status);
if (!amount.equals(this.total())) { // правило «только на полную сумму»
throw new PartialPaymentNotAllowed(this.id, this.total(), amount);
}
this.#status = OrderStatus.PAID;
return new OrderPaid(this.id, amount, method); // событие из раздела «События»
}
// Правило «сумма заказа равна сумме позиций минус скидка» — вычисление, а не поле
total() {
return this.#lines
.reduce((sum, line) => sum.add(line.amount()), Money.zero(this.currency()))
.minus(this.discount);
}
addLine(product, quantity, price) {
if (this.#status !== OrderStatus.CONFIRMED) throw new OrderAlreadyProcessed(this.id, this.#status);
if (this.#lines.length >= 100) throw new TooManyLines(this.id); // правило «не больше 100»
this.#lines.push(new OrderLine(product, quantity, price)); // цена зафиксирована здесь
}
}
// Понятие-значение: равенство по значению, неизменяемость, своя арифметика
class Money {
constructor(amount, currency) { // amount в минимальных единицах валюты
this.amount = amount;
this.currency = currency;
Object.freeze(this);
}
add(other) {
if (this.currency !== other.currency) throw new CurrencyMismatch(this.currency, other.currency);
return new Money(this.amount + other.amount, this.currency);
}
equals(other) { return this.amount === other.amount && this.currency === other.currency; }
}
// Позиция: внутри агрегата, своей жизни снаружи не имеет
class OrderLine {
constructor(product, quantity, price) {
Object.assign(this, { product, quantity, price });
Object.freeze(this);
}
amount() { return this.price.multiply(this.quantity); }
}
# Понятие с идентичностью и правилами -> корень агрегата
class Order:
def __init__(self, id: OrderId, customer_id: CustomerId, status: OrderStatus, discount: Money):
self.id = id
self.customer_id = customer_id # связь по идентификатору: покупатель в другом контексте
self._lines: list[OrderLine] = [] # связь внутри агрегата: «состоит из»
self._status = status # состояние из раздела «Состояния»
self.discount = discount
self.version = 0
# Правило «оплатить можно только подтверждённый» — переход состояния, не сеттер
def pay(self, amount: Money, method: PaymentMethod) -> OrderPaid:
if self._status is not OrderStatus.CONFIRMED:
raise OrderNotPayable(self.id, self._status)
if amount != self.total(): # правило «только на полную сумму»
raise PartialPaymentNotAllowed(self.id, self.total(), amount)
self._status = OrderStatus.PAID
return OrderPaid(self.id, amount, method) # событие из раздела «События»
# Правило «сумма заказа равна сумме позиций минус скидка» — вычисление, а не поле
def total(self) -> Money:
return sum((line.amount() for line in self._lines), Money.zero(self.currency())) - self.discount
def add_line(self, product: ProductId, quantity: int, price: Money) -> None:
if self._status is not OrderStatus.CONFIRMED:
raise OrderAlreadyProcessed(self.id, self._status)
if len(self._lines) >= 100: # правило «не больше 100»
raise TooManyLines(self.id)
self._lines.append(OrderLine(product, quantity, price)) # цена зафиксирована здесь
# Понятие-значение: равенство по значению, неизменяемость, своя арифметика
@dataclass(frozen=True)
class Money:
amount: Decimal
currency: Currency
def __add__(self, other: "Money") -> "Money":
if self.currency != other.currency:
raise CurrencyMismatch(self.currency, other.currency)
return Money(self.amount + other.amount, self.currency)
# Позиция: внутри агрегата, своей жизни снаружи не имеет
@dataclass(frozen=True)
class OrderLine:
product: ProductId
quantity: int
price: Money
def amount(self) -> Money:
return self.price * self.quantity
Соответствие построчно, и оно и есть весь смысл упражнения:
| Строка на странице | Что получилось в коде |
|---|---|
| «Заказ — то, что покупатель собрал» + правила | тип Order, корень агрегата |
| «Заказ состоит из одной или более позиций» | список позиций внутри Order, а не отдельный репозиторий |
| «Заказ принадлежит одному покупателю» | CustomerId, а не объект Customer: покупатель — другой контекст |
| «Денежная сумма складывается только с той же валютой» | Money как значение с проверкой в add |
| «подтверждён → оплачен → …, из отменён выхода нет» | OrderStatus и проверки перехода внутри методов |
| «Оплатить можно только подтверждённый, на полную сумму» | два условия в начале pay, с осмысленными ошибками |
| «Заказ оплачен (заказ, сумма, способ)» | OrderPaid, возвращаемый из метода |
| «Цена фиксируется в момент подтверждения» | price хранится в OrderLine, а не берётся из товара при чтении |
Три вещи, которые видно только на таком сопоставлении. Первое: правила стали кодом, а не документацией — каждое проверяемое правило со страницы превратилось в условие, и его нельзя обойти, потому что состояние меняется только через методы. Второе: у события есть автор — метод, который его вернул; событие не собирается «где-то в сервисе». Третье: открытые вопросы остались открытыми — «можно ли менять адрес после оплаты» в коде пока разрешено, и это расхождение теперь видно, а не спрятано.
И проверка в обратную сторону, самая полезная: возьмите класс и попробуйте прочитать его вслух как страницу понятий. Если получается «у заказа есть поле статус, поле сумма, поле список» — модель пока про данные. Если получается «заказ можно оплатить только подтверждённый и только полностью» — модель про домен.
Агрегат — не таблица
Отдельная ловушка на этом пути — подменить модель понятий схемой хранения. Симптомы знакомы: агрегат «нарезан» ровно по таблице; «заказ» и «позиции» — два независимых репозитория, потому что «это же две таблицы»; понятие «просроченный платёж» отсутствует в модели, потому что «это просто WHERE».
Правильное направление зависимости обратное: модель понятий первична, схема БД — способ её хранения. Один агрегат может лежать в трёх таблицах; репозиторий существует ровно для того, чтобы спрятать это от домена. Проектируй понятия → агрегаты → и только потом схему, под запросы и нагрузку. Подробный разбор этой ловушки — в статье «Язык домена: понятия, словарь и граф знаний».
Где она живёт и кто её обновляет
Записанная система понятий бесполезна, если о ней забыли через месяц. Три вопроса, на которые нужен ответ до того, как страница написана.
Где лежит: в репозитории, рядом с кодом контекста. Один файл на контекст, например docs/domain/order-context.md. Причина не в аккуратности, а в механике: файл в репозитории правится в том же изменении, что код, проходит то же ревью и виден тому, кто открыл проект. Та же страница в вики живёт отдельной жизнью и расходится с кодом за пару месяцев — потому что её правка не входит ни в одну задачу.
Кто обновляет: тот, кто меняет поведение. Появилось новое правило («нельзя оформить второй заказ, пока не оплачен первый») — строка в файле добавляется в том же пул-реквесте, что и проверка в коде. Ревьюер смотрит оба места. Это то же правило, что для спецификации системы: описание меняется вместе с поведением или не меняется вообще.
Как понять, что разошлась. Признаки, которые видны без специальных усилий:
- В коде есть исключение, которого нет в правилах на странице (кто-то добавил проверку, не записав правило).
- На странице есть правило, под которое в коде не находится ни проверки, ни теста.
- В коде появилось понятие с именем, которого нет на странице, — или, что хуже, понятие с именем со страницы, но другим смыслом.
- Новый человек задаёт вопрос, ответ на который есть в коде и отсутствует на странице.
Дешёвая механическая проверка: у каждого правила на странице есть имя теста, который его проверяет, а у каждого теста инварианта — номер правила в имени или комментарии. Тогда расхождение находится поиском: правило без теста и тест без правила видны сразу. Это тот же приём, что превращение решения в проверку, и он единственный, который работает без дисциплины.
Раз в квартал — сверка вслух. Полчаса: пройти по странице с тем, кто знает домен, и спросить «это всё ещё так?». Обычно находится два-три изменившихся правила, о которых код знает, а страница нет, — и одно, о котором не знает никто.
Эксперты говорят по-разному
Норма, а не исключение: два человека из бизнеса дают разные определения одного слова, и оба уверены. «Активный клиент» для продаж — тот, кто купил за 90 дней; для поддержки — тот, у кого есть открытое обращение. Что с этим делать — вопрос, который решает, получится ли единый язык.
Сначала — не выбирать. Первый порыв «давайте договоримся об одном определении» обычно неверен: определения разные потому, что работа разная, и попытка свести их к одному даёт понятие, которым не пользуется никто. Правильный первый шаг — записать оба определения с пометкой, чья это область.
Потом — проверить, не два ли это понятия. Почти всегда да. И тогда ответ найден: это два разных понятия в двух разных контекстах, и у каждого своё имя. Не «активный клиент» с двумя смыслами, а «покупавший недавно» и «клиент с открытым обращением». Так противоречие превращается в границу контекста — то есть в самое ценное, что можно найти на этом этапе.
Если это действительно одно понятие (оба говорят об одном и том же, но по-разному его описывают), решает владелец области: тот, для кого это понятие центральное. Не голосованием и не старшинством. Решение записывается вместе с датой и с тем, чьё оно, — потому что через полгода спор вернётся.
Чего не делать: оставлять противоречие незаписанным («потом разберёмся»), придумывать компромиссное определение, которое не соответствует ни одной из практик, и — самое частое — молча выбирать то определение, которое проще реализовать.
На событийном штурме такие расхождения ловят специальным приёмом: их отмечают как горячую точку прямо на стене и не обсуждают до конца во время встречи, чтобы не утонуть в одном слове. Разбор — в статье про штурм. А в вашей записи для них есть раздел «Открытые вопросы» из примера выше: имя вопроса, имя того, кто ответит, и дата.
Онтология, спека и AI
У явной системы понятий сегодня есть третий потребитель, помимо команды и кода, — AI-агент. Записанная онтология (словарь, состояния, инварианты) — это то, что превращает промпт в спецификацию: агент не угадывает, можно ли оплатить отменённый заказ, — это записано. Весь конвейер выглядит так: онтология → доменная модель → спека → код (человеком или агентом) → приёмка по инвариантам. DDD и работа с AI здесь не конкурируют, а стоят на одном фундаменте — явной системе понятий. Базовый разбор системы понятий без DDD-контекста — в уже упомянутом «Языке домена».
Конвейер в деле: одно правило от страницы до приёмки
Раздел обещает путь «система понятий → модель → спецификация → код → приёмка», и стоит показать все звенья на одном правиле. Возьмём со страницы выше: «оплатить можно только подтверждённый заказ, и только на полную сумму».
Звено 1. Правило на странице (уже есть, см. пример выше).
Звено 2. Понятие в модели — метод pay с двумя проверками (код в разделе выше). Обратите внимание: правило превратилось не в комментарий, а в условие с именованным исключением.
Звено 3. Спецификация для агента — то же правило в виде проверяемого сценария:
Сценарий: оплата неподтверждённого заказа
- ЕСЛИ заказ в состоянии «отменён»
- КОГДА поступает оплата на полную сумму
- ТОГДА оплата отклоняется с кодом ORDER_NOT_PAYABLE
- И состояние заказа не меняется
- И событие «Заказ оплачен» не публикуется
Сценарий: частичная оплата
- ЕСЛИ заказ подтверждён на сумму 5000 RUB
- КОГДА поступает оплата на 3000 RUB
- ТОГДА оплата отклоняется с кодом PARTIAL_PAYMENT_NOT_ALLOWED
- И состояние заказа не меняется
Звено 4. Код пишет агент по этой спецификации — и именно здесь видно, зачем нужны предыдущие звенья: без них он напишет setStatus(PAID) с проверкой снаружи, потому что так короче.
Звено 5. Приёмка по инвариантам — тест, который проверяет правило, а не реализацию:
@Test
void cancelledOrder_cannotBePaid() {
var order = anOrder().status(CANCELLED).total(money(5_000)).build();
assertThatThrownBy(() -> order.pay(money(5_000), CARD))
.isInstanceOf(OrderNotPayable.class);
assertThat(order.status()).isEqualTo(CANCELLED);
}
@Test
void partialPayment_isRejected() {
var order = anOrder().status(CONFIRMED).total(money(5_000)).build();
assertThatThrownBy(() -> order.pay(money(3_000), CARD))
.isInstanceOf(PartialPaymentNotAllowed.class);
}
func TestCancelledOrder_CannotBePaid(t *testing.T) {
order := anOrder().Status(Cancelled).Total(money(5_000)).Build()
_, err := order.Pay(money(5_000), Card)
var notPayable *OrderNotPayable
require.ErrorAs(t, err, ¬Payable)
require.Equal(t, Cancelled, order.Status())
}
func TestPartialPayment_IsRejected(t *testing.T) {
order := anOrder().Status(Confirmed).Total(money(5_000)).Build()
_, err := order.Pay(money(3_000), Card)
var partial *PartialPaymentNotAllowed
require.ErrorAs(t, err, &partial)
}
it('cancelled order cannot be paid', () => {
const order = anOrder().status(OrderStatus.CANCELLED).total(money(5_000)).build();
expect(() => order.pay(money(5_000), CARD)).toThrow(OrderNotPayable);
expect(order.status).toBe(OrderStatus.CANCELLED);
});
it('partial payment is rejected', () => {
const order = anOrder().status(OrderStatus.CONFIRMED).total(money(5_000)).build();
expect(() => order.pay(money(3_000), CARD)).toThrow(PartialPaymentNotAllowed);
});
def test_cancelled_order_cannot_be_paid():
order = an_order().status(OrderStatus.CANCELLED).total(money(5_000)).build()
with pytest.raises(OrderNotPayable):
order.pay(money(5_000), PaymentMethod.CARD)
assert order.status is OrderStatus.CANCELLED
def test_partial_payment_is_rejected():
order = an_order().status(OrderStatus.CONFIRMED).total(money(5_000)).build()
with pytest.raises(PartialPaymentNotAllowed):
order.pay(money(3_000), PaymentMethod.CARD)
Что делает эту цепочку работающей: на каждом звене одно и то же правило узнаваемо. Формулировка на странице, условие в методе, сценарий в спецификации, имя теста — всё говорит про «только подтверждённый и только полностью». Поэтому изменение правила проходит по всей цепочке: поправили страницу — видно, какой сценарий и какой тест надо менять.
И обратная польза, за которой всё это и строят для работы с агентом: тест, написанный из правила, невозможно подогнать под код. Агент, который сделал иначе, получает красный тест — а не одобрение от того, кто читает диф и сверяет его со своим представлением.
Глубже: агрегат в трёх таблицах: как репозиторий сохраняет целикомрасширенное
«Один агрегат может лежать в трёх таблицах» выше требует показать, как именно, потому что здесь ломается половина реализаций: репозиторий сохраняет корень и забывает удалить исчезнувшие строки, или отдаёт заказ без позиций.
Заказ хранится в orders, его позиции в order_line, скидки в order_discount. Загрузка это два-три запроса по идентификатору корня (или один с соединением и группировкой), после которых репозиторий собирает объект целиком; половину агрегата он не отдаёт никогда, потому что инварианты считаются по всему («итог равен сумме строк»), и агрегат без строк это не агрегат, а ошибка. Ленивая подгрузка коллекций внутри агрегата противоречит этому по определению.
Сохранение это одна транзакция с тремя действиями. Корень обновляется с проверкой версии (WHERE id = ? AND version = ?), о чём раздел про одновременные правки в статье о тактических паттернах. Коллекция сверяется с базой: строки, которых больше нет в агрегате, удаляются, новые вставляются, изменённые обновляются; для этого у дочерних строк есть стабильные идентификаторы, а не позиции в списке. Проще всего это делается двумя командами, удалить все строки корня и вставить заново, что честно и достаточно для коллекций в десятки элементов; сравнение построчно берут, когда строк сотни и на них есть внешние ссылки. Всё это внутри репозитория, за интерфейсом «найти по идентификатору, сохранить», и домен об этих трёх таблицах не знает.
С jOOQ это выглядит как явный код в реализации репозитория: fetch по трём таблицам, сборка, store с версией, deleteFrom(ORDER_LINE).where(ORDER_ID.eq(id)) и пакетная вставка. С JPA то же самое достигается каскадом и orphanRemoval, но там легче получить и ленивую подгрузку, и половину агрегата, поэтому настройки проверяют тестом: загрузить, убрать строку, сохранить, загрузить снова, строки нет.
С pgx это явный код в реализации репозитория: три запроса на чтение и сборка структуры, UPDATE ... WHERE id = $1 AND version = $2 с проверкой RowsAffected, DELETE FROM order_line WHERE order_id = $1 и пакетная вставка через CopyFrom или Batch, всё в одной транзакции. Каскадов ORM нет, поэтому «половину агрегата» случайно не загрузишь, зато забыть удалить строки можно, и репозиторий проверяют тестом: загрузить, убрать строку, сохранить, загрузить снова, строки нет.
С голым pg или Drizzle это явный код в реализации репозитория: три запроса на чтение и сборка объекта, UPDATE ... WHERE id = $1 AND version = $2 с проверкой rowCount, DELETE FROM order_line WHERE order_id = $1 и пакетная вставка в одной транзакции. С Prisma то же самое пишут вложенной записью lines: { deleteMany: {}, create: [...] } внутри $transaction, но там легче загрузить заказ без позиций (include забыт), поэтому репозиторий проверяют тестом: загрузить, убрать строку, сохранить, загрузить снова, строки нет.
С SQLAlchemy то же самое достигается relationship(..., cascade="all, delete-orphan"), и ловушки те же, что у JPA: ленивая подгрузка позиций вне сессии и половина агрегата в памяти, поэтому связь объявляют lazy="selectin" или грузят явно через selectinload, а настройки проверяют тестом: загрузить, убрать строку, сохранить, загрузить снова, строки нет. С голым psycopg это явные три запроса, UPDATE ... WHERE id = %s AND version = %s с проверкой rowcount, удаление строк и executemany в одной транзакции.
И граница из статьи про размер агрегата здесь получает физический смысл: коллекция, которая не влезает в разумное число строк на одно сохранение, это отдельный агрегат со своим репозиторием, а не таблица внутри этого.
Коротко
- Доменная модель вырастает из онтологии — системы понятий домена (понятия, связи, состояния, события, правила). DDD-практики делают её явной и общей. Пять элементов онтологии почти механически ложатся на тактические паттерны: объект → Entity/Aggregate, значение → VO, состояние → статусная модель, событие → Domain Event, правило → инвариант.
- Граница агрегата идёт от правил: агрегат — кластер понятий, который меняется вместе ради инварианта. Правило даёт кандидата, а выбор между атомарностью и отложенной сверкой остаётся за нагрузкой и ценой рассогласования.
- Агрегат ≠ таблица: модель понятий первична, схема БД — способ хранения, репозиторий прячет её от домена. Явная онтология — общий фундамент DDD и работы с AI: из неё вырастают и агрегаты, и спека для агента.
- Агрегат в трёх таблицах загружают целиком по корню и сохраняют одной транзакцией: корень с версией, коллекция сверкой или удалением и вставкой заново; половину агрегата репозиторий не отдаёт.
- Страница понятий — это одно предложение на понятие, связи с количеством, состояния переходами, пронумерованные правила и открытые вопросы с именем отвечающего; страница на контекст, не три.
- Это не схема данных и не диаграмма классов: в понятиях есть то, чего нет в таблицах («просроченный платёж»), правила первичны, а проверка простая — текст целиком понимает человек из бизнеса.
- Раскладка в код буквальная: связь «состоит из» даёт коллекцию внутри агрегата, связь с другим контекстом — идентификатор, правило — условие в методе с именованным исключением, событие возвращается методом.
- Страница живёт в репозитории рядом с кодом и правится в том же изменении; расхождение ищут по правилам без тестов и тестам без правил, раз в квартал сверяют вслух с тем, кто знает домен.
- Противоречия экспертов — норма: записать оба определения, проверить, не два ли это понятия (обычно да, и это граница контекста), и только для действительно одного решает владелец области с датой.
Что почитать дальше
- Тактические паттерны — агрегат, репозиторий, версия и чтение мимо агрегата.
- Event Storming — как добыть систему понятий у бизнеса.
- Спецификация из модели — как записанная онтология становится спекой для агента.