Спроектируйте модель, затем код.

Модель данных обычно начинают с ORM. Но ORM описывает не предметную область, а базу, построенную на ней; для NoSQL модели сущностей не появляется вовсе. AOA описывает её такой, какая она есть, — одинаково для SQL и для NoSQL.

Базу строят не для понимания, а для надёжного хранения и быстрого чтения: сначала нормализация, потом денормализация ради скорости — предметная область искажается дважды, в противоположные стороны. Здесь она строится по шагам: сначала карта доменов, затем сущности, которые её наполняют, отношения между ними и жизненный цикл каждой. Всё это упаковывается в Resource — через него реальная база читается в модель.

Документация ↗

Карта доменов

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

Карта доменов — это ответ на первый вопрос. На ней — только области, из которых состоит система: StoreDomain — управление заказами, BillingDomain — биллинг, MessagingDomain — рассылки. Такой ответ без деталей охватывает систему целиком и помещается в голове — как первая опора.

Карта доменов
Полная документация ↗

Сущности выражают домен

В домене две главные вещи: Entity — заказ, клиент, позиция — и Action, именованные операции над ними. Ни контроллеров, ни таблиц, ни прочей инфраструктуры. Это позволяет спуститься на шаг глубже и удержать фокус на бизнес-намерениях системы, а не на технической реализации.

Entity — обычный класс: имя, типизированные поля и больше ничего. Он ничего не знает ни про ORM, ни про таблицы, ни про то, откуда придут данные, — его единственная задача описать объект реального мира так, как о нём говорят: у заказа есть сумма и валюта, у клиента — имя и почта. Поля бывают двух родов: простые — строка, число, дата — и ссылки на другие Entity, потому что у заказа есть клиент и есть позиции. Вторые и превращают набор отдельных классов в модель — о них следующий раздел. При этом поведения у Entity нет: он только описывает, а всё, что с объектом делают, живёт в Action.

Что такое Action →
CustomerEntityPKidstrnamestremailstrOrderEntityPKidstrtotalfloatcurrencystrstatusstr
Полная документация ↗

Связи явно задают владение

Из чего домен состоит, уже понятно. А как он держится и что не даёт связям разойтись?

Связь описывают двумя вещами: насколько крепко объекты держатся друг за друга — композиция, агрегация или ассоциация — и сколько объектов стоит с каждой стороны, один или несколько. Каждая сущность объявляет связь целиком: и свою сторону, и встречную, с типом и кратностью у обеих. Поэтому достаточно посмотреть на одну сущность, чтобы увидеть все её отношения сразу. На старте система сверяет каждую пару — разошлись, и запуск останавливается. Иногда второй стороны нет: связь смотрит за пределы своей базы. Это тоже можно, но объявляется явно.

OrderEntityPKidstrtotalfloatcurrencystrstatusstrFKcustomer→ CustomerEntityFKlines→ OrderLineEntityCustomerEntityPKidstrnamestremailstrFKorders→ OrderEntityOrderLineEntityPKidstrskustrFKorder→ OrderEntity
Полная документация ↗

Lifecycle контролирует каждый переход

Пока статус — просто строка, ничто не мешает заказу попасть из черновика сразу в доставленные. Что разрешено, а что нет, живёт в голове у команды или в комментарии, который давно устарел.

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

Автомат объявляется сам по себе: какие состояния может принимать заказ и какие переходы между ними разрешены. Один раз, отдельно от любой сущности.

draftpaidshippeddeliveredcancelled

Теперь его можно применить: у OrderEntity поле status перестаёт быть строкой и получает тип OrderLifecycle. Строка принимала любое значение — этот тип только те, до которых есть путь.

OrderEntityPKidstrtotalfloatcurrencystrlifecycleOrderLifecycle
Полная документация ↗

Resources задают границу хранилища

Модель описывает предметную область, но с базами данных напрямую не работает: она не должна зависеть от их реализации. Значит, кто-то другой должен доставать данные из реальной базы — и класть обратно.

Это и есть работа Resource. Он держит всё, что должно жить между вызовами: соединение, пул, клиента. Его дело — открыть, выполнить, вернуть; бизнес-правил внутри нет. Через эту границу проходят сущности. Одна и та же модель может быть представлена сразу в нескольких Resource: один читает из SQL, другой из NoSQL, третий из чужого HTTP-сервиса, и все возвращают тот же OrderEntity. Код бизнес-логики не знает, какой вариант Resource был использован: ни имени таблицы, ни диалекта, ни формата ответа в него не попадает. Поэтому сменить хранилище — значит поменять только Resource.

кодResourceOrderEntityNoSQLSQLHTTP-сервисодин интерфейс · тот же OrderEntity · подменяемый источник
Три вида Resource: Storage, Gateway, Controller ↗Полная документация ↗

Проекции формируют нужный срез

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

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

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

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

ERD-модель в Maxitor →

get_order(order_id) → OrderEntity

Очевидный случай — вся сущность, каждое поле загружено; класс модели, используемый ровно так, как объявлен.

OrderResourceget_order()find_orders()get_full_order_info()OrderEntityPKid"ORD-1024"total149.90currency"RUB"status"paid"возвращает

find_orders(query) → list[OrderEntity]

Тонкая коллекция — загружен только id каждого заказа; чтение любого другого поля вызвало бы ошибку.

OrderResourceget_order()find_orders()get_full_order_info()OrderEntityPKid"ORD-1024"totalcurrencystatusвозвращает

get_full_order_info(order_id) → OrderEntity

Глубокий случай — заказ плюс его вложенные сущности, каждая загружена до своей частичной глубины (id загружается всегда, это ключ).

OrderResourceget_order()find_orders()get_full_order_info()OrderEntityPKid"ORD-1024"total149.90FKcustomer→ CustomerEntityFKlines→ OrderLineEntityCustomerEntityPKid"CUST-7"name"Ivan Petrov"emailOrderLineEntityPKid"LN-1"sku"SKU-55"quantity3unit_priceвозвращает
Полная документация ↗
Спроектируйте модель, затем код. · aoa.run