Исполняемые спецификации на flang DDD: bounded context, ubiquitous language и command guard
0%

DDD: bounded context, ubiquitous language и command guard

DDD: bounded context, ubiquitous language и command guard

Глава разбирает, где модуль flang совпадает с bounded context из DDD, а где заканчивается. flang хорошо описывает факты, состояния и правила перехода и умеет доказывать, что правило держится на любом входе. Агрегат, репозиторий и шину событий он не заменяет.

Минимальный пример

Правило «отгружать можно только собранный и оплаченный заказ» в виде функции:

модуль «Исполнение заказа»

тип «Состояние заказа»
  вариант «Создан»
  вариант «Собран»
  вариант «Отгружен»

объект «Заказ»
  «номер»: строка
  «стадия»: «Состояние заказа»
  «оплачен»: признак

тотальная функция «Отгрузка разрешена»
  принимает заказ: «Заказ»
  возвращает признак
  обеспечивает «отгружают только собранный заказ» если не (заказ.«стадия» равен (вариант «Собран»)) то результат равен нет иначе да
  обеспечивает «отгружают только оплаченный заказ» если не заказ.«оплачен» то результат равен нет иначе да
  пример «собранный и оплаченный заказ можно отгрузить»
    дано заказ равно запись «Заказ» с «номер» равным "ЗК-7781" и «стадия» равным (вариант «Собран») и «оплачен» равным да
    ожидается да
  если заказ.«стадия» равен (вариант «Собран»)
    то заказ.«оплачен»
    иначе нет

Состояние заказа — тип с вариантами, а не строка status. Правило — функция «Отгрузка разрешена», а две строки обеспечивает записывают, что эта функция обещает на любом входе.

Что делает компилятор

flang check разбирает модуль, проверяет типы полей и вариантов, доказывает, что функция завершается, и прогоняет пример:

модуль «Исполнение заказа»: функций 1, из них с доказанным завершением 1; типов 2
shipment.flang: проверено — разбор, типы, завершаемость, ядро и примеры; замечаний нет

Опечатка в имени варианта до ревью не доедет:

FLANG_UNKNOWN_NAME в файле shipment.flang, строка 16, столбец 82: неизвестный конструктор варианта «Собрн»

flang check --proof идёт дальше и отвечает на вопрос, держатся ли обещания на всех входах, а не только на написанном примере:

  постусловие «отгружают только собранный заказ» функции «Отгрузка разрешена» — доказано сведением цели с телом функции: правило «разбор случаев по внутреннему условию цели», объявленные типы аргументов не понадобились — утверждение обо ВСЕХ входах, а не о написанных; теоремы при нём нет и не нужно
shipment.flang: ПРОВЕРЕНО САМОСТОЯТЕЛЬНО — утверждений 2: доказано 2, условно 0, сетка 0, объявлено, не доказано 0, отвергнуто 0, нарушено 0; законов на сетке 0, на веру 0 — код возврата 0

Код возврата 0 значит «доказано всё». Код 3 значит, что часть обещаний проверена только на примерах; в отчёте это называется «сетка N», где N — число значений, на которых обещание посчитали.

Модуль как bounded context

Один модуль — один bounded context, а не вся предметная область компании. «Исполнение заказа» отвечает за переход от сборки к отгрузке. Оплата, бухгалтерия и складской учёт — другие контексты со своими модулями, даже если они читают ту же таблицу заказов.

Имена внутри модуля обязаны совпадать с языком, на котором говорит бизнес. Если склад говорит «заказ собран», в модели стоит вариант «Собран», а не status === 3. Ubiquitous language здесь не глоссарий в вики: имена функций, вариантов, обещаний и примеров — часть программы. Пример «собранный и оплаченный заказ можно отгрузить» прогоняется на каждой проверке, и обещание «отгружают только оплаченный заказ» доказывается теми же словами, которыми его сформулировал эксперт.

Состояние — тип с вариантами. Компилятор требует, чтобы разбор состояния назвал каждый вариант. Добавили вариант «Отгружен», а функцию не поправили — проверка откажет:

FLANG_MATCH_NOT_EXHAUSTIVE в файле shipment.flang, строка 19, столбец 3: разбор «Состояние заказа» не покрывает «Отгружен»

Переход состояния как функция

Разрешённый переход — функция, которая по состоянию решает, можно ли выполнить команду, и обещания при ней. Она не выполняет отгрузку и не двигает деньги: она отвечает «да» или «нет» и может назвать причину. Реальный переход — запись в базу, вызов курьерской службы — остаётся кодом снаружи. Это прямое соответствие принципу DDD: правило предметной области отделено от эффектов.

Полная модель лежит в static/spec/models/order-lifecycle.flang. В ней кроме допуска есть команда «Отгрузить», которая возвращает тип «Итог команды»: вариант «Принято» с новой стадией или «Отклонено» с причиной. Причина отказа — значение, а не исключение.

Обещание вида если не … то результат равен нет иначе да — это импликация: «если заказ не собран, ответ — нет». Ядро доказывает такие обещания, когда условие в теле написано сравнением. Есть ловушка: то же условие, вынесенное в отдельную функцию с разбор, ядро уже не раскрывает.

модуль «Исполнение заказа»

тип «Состояние заказа»
  вариант «Создан»
  вариант «Собран»
  вариант «Отгружен»

объект «Заказ»
  «номер»: строка
  «стадия»: «Состояние заказа»
  «оплачен»: признак

тотальная функция «Собран ли»
  принимает стадия: «Состояние заказа»
  возвращает признак
  пример «созданный ещё не собран»
    дано стадия равно вариант «Создан»
    ожидается нет
  разбор стадия
    случай «Создан»
      то нет
    случай «Собран»
      то да
    случай «Отгружен»
      то нет

тотальная функция «Отгрузка разрешена»
  принимает заказ: «Заказ»
  возвращает признак
  обеспечивает «отгружают только собранный заказ» если не («Собран ли» от заказ.«стадия») то результат равен нет иначе да
  обеспечивает «отгружают только оплаченный заказ» если не заказ.«оплачен» то результат равен нет иначе да
  пример «собранный и оплаченный заказ можно отгрузить»
    дано заказ равно запись «Заказ» с «номер» равным "ЗК-7781" и «стадия» равным (вариант «Собран») и «оплачен» равным да
    ожидается да
  если «Собран ли» от заказ.«стадия»
    то заказ.«оплачен»
    иначе нет

Программа верна, пример проходит, но оба обещания падают до проверки на примере:

shipment.flang: НЕ ДОКАЗАНО — ОПОРА НЕ СУДИЛАСЬ — утверждений 2: доказано 0, условно 0, сетка 2, объявлено, не доказано 0, отвергнуто 0, нарушено 0; законов на сетке 0, на веру 0 — код возврата 3

Правило отсюда простое: условие допуска пишите сравнением прямо в теле (заказ.«стадия» равен (вариант «Собран»)), а разбор оставляйте для функций, которые возвращают разное по каждому варианту.

Command guard: допуск выносит компилятор

examples/spec/shipment-guard/guard.mjs показывает, как выглядит допуск команды на практике. Модель — static/spec/models/order-shipment.flang, в ней функция «Заказ с номером отгружается» находит заказ в списке по номеру и спрашивает «Можно отгрузить». Утверждение фиксирует приложение, а не агент и не пользователь:

export const CLAIM = '«Заказ с номером отгружается» от заказы, номер равно да';

export async function decideShipment(orderNumber, orders) {
  const verdict = await (await flang()).facts(MODEL_FILE, { заказы: orders, номер: String(orderNumber) }, [CLAIM]);
  const [result] = verdict.results;
  return { allowed: verdict.ok && result.holds === true, claim: CLAIM, why: result.why, steps: result.steps ?? [] };
}

Под капотом это flang facts: компилятор вычисляет утверждение на фактах JSON и возвращает вердикт с шагами вычисления. Разница с обычным if (order.readyToShip) ship() в том, что решение — не флаг, посчитанный где-то раньше, а результат вычисления доказуемо завершающейся функции на переданном снимке данных.

node examples/spec/shipment-guard/guard.mjs ЗК-7781

даёт "allowed": true и шаги: какие факты взяты, какая функция вызвана, что она вернула. Если склад не подтвердил заказ, те же номер и модель с другим снимком:

node examples/spec/shipment-guard/guard.mjs ЗК-7781 --blocked

возвращают "allowed": false и причину словами компилятора:

«Заказ с номером отгружается» от факта «заказы», факта «номер» = нет, а утверждение требует «равно да». Исходные факты — «заказы»: [{"номер":"ЗК-7781","клиент":"ООО Маяк","оплачен":true,"склад подтвердил":false}]; «номер»: «ЗК-7781»

Процесс завершается кодом 1, поэтому guard пригоден и для shell, и для CI, и для пайплайна с агентом.

Номер заказа уходит в факты как данные, а не вклеивается в текст программы. Подставить через него чужую логику нельзя: guard.test.mjs проверяет, что номер с кавычками просто не находит заказ.

Допуск не заменяет optimistic lock. Между снимком данных и записью состояние может измениться, поэтому транзакционная граница и проверка версии остаются обязанностью приложения.

Где проходит граница агрегата

flang не хранит состояние и не управляет параллельным доступом: в языке нет ни изменяемых переменных, ни побочных действий в функциях. Модель описывает факты и правило перехода; application service собирает снимок агрегата, спрашивает модель и только при успехе выполняет команду. Кладите модель рядом с bounded context, а не в общую папку правил:

src/order-execution/
  domain/
  application/
  policies/
    shipment.flang
    shipment.facts.example.json

Версионируйте политику как код: pull request с правкой .flang показывает изменённое правило, изменённые примеры и изменённые обещания, а CI прогоняет flang check --proof.

Практика в песочнице

Уберите заказ.«оплачен» из тела «Отгрузка разрешена» (оставьте только сравнение стадии) и запустите проверку. Обещание об оплате упадёт до «сетки 1»: единственный пример его не опровергает. Затем добавьте пример «собранный, но неоплаченный заказ не отгружается» с ожиданием нет — проверка откажет с FLANG_PROPERTY.

В примере «оплаченный и подтверждённый можно» поменяйте «склад подтвердил» на нет и запустите проверку: функция вернёт нет, а пример ждёт да, и компилятор назовёт пример, который не сошёлся.

Типичные ошибки

  • FLANG_UNKNOWN_NAME — опечатка в имени варианта, поля или функции. Ловится до проверки типов.

  • FLANG_MATCH_NOT_EXHAUSTIVE — разбор состояния забыл вариант. Цепочка если … иначе если такой ошибки не даст: забытый случай молча уйдёт в последнее иначе. Поэтому ветвление по состоянию — через разбор.

  • FLANG_PROPERTY — пример нарушил обещание:

    FLANG_EXAMPLE: пример «собранный, но неоплаченный заказ не отгружается» функции «Отгрузка разрешена»: FLANG_PROPERTY: нарушено свойство «отгружают только оплаченный заказ» функции «Отгрузка разрешена»
    
  • «сетка N» там, где ждали «доказано». Сама проверка не падает (flang check без --proof даёт код 0), поэтому в CI нужен flang check --proof, а отчёт стоит читать.

  • Ошибка, которую компилятор не поймает: правило записано и доказано, но неверно по сути. Ядро доказывает, что функция держит обещание, а не то, что эксперт сформулировал правило правильно.

Упражнение

Возьмите команду с реальным бизнес-отказом из своего проекта — approve, refund, publish или deploy. Опишите состояние типом с вариантами, допуск — функцией с двумя обещаниями-импликациями, и добейтесь «доказано 2» в отчёте flang check --proof. Затем соберите два снимка фактов, на которых flang facts должен и не должен давать допуск. Отдельно, текстом вне модели, перечислите технические условия (лок, идемпотентность, ретраи), которые остаются в приложении.

Чек-лист

  • Модуль покрывает один bounded context, а не всю систему.
  • Имена полей, вариантов и обещаний совпадают с языком эксперта дословно.
  • Состояние — тип с вариантами, ветвление по нему — разбор.
  • Допуск — функция, которая отвечает «да» или «нет»; эффект вызывается снаружи.
  • Обещания допуска доказаны (доказано в отчёте), а не держатся на примерах.
  • Command guard спрашивает модель на актуальном снимке (flang facts), а не хранит «уже посчитанный» флаг.
  • Optimistic lock и транзакционная граница остаются в application service.
  • Модель лежит рядом с bounded context и ревьюится как код.

Кейсы каталога по этой теме

Дальше: AI-агенты и Digit

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов
Дальше