Коли я почала серйозніше працювати з DDD, у мене був простий внутрішній страх: якщо вже є доменна модель, то всі дані треба читати через неї.
Інакше ніби виходить, що ми "обходимо DDD".
На практиці це швидко почало заважати. Список для адміністративної таблиці потребував кілька полів, фільтри, сортування, підрахунки і дані з кількох таблиць. А domain model була створена зовсім для іншого: захищати правила зміни стану.
Після матеріалі про aggregate і межу узгодженості ця різниця стала для мене особливо очевидною: aggregate визначає межу узгодженості, але це не означає, що кожен SELECT повинен відтворювати весь aggregate.
Запис і читання відповідають на різні питання
Сторона запису ставить такі питання: - Чи дозволений цей перехід?- Який інваріант має залишатися істинним?- Хто володіє цим станом?- Що має змінюватися атомарно? Сторона читання ставить такі питання: - Які стовпці потрібні?- Як слід сортувати рядки?- Які фільтри застосовуються?- Які підсумкові значення слід відобразити?Це не означає, що читання і запис повинні бути двома окремими застосунками.
Мені достатньо визнати, що вони оптимізовані під різні питання.
Для зміни стану я хочу сильну доменну модель. Для таблиці я хочу простий і точний запит, який повертає саме ті дані, які треба показати.
Спочатку я намагалася читати через ті самі моделі
$products = $productsRepository ->all(); return $products->map( fn (Product $product) => [ 'id' => $product->id(), 'name' => $product->name(), 'status' => $product->status(), 'registrations' => $product->registrationsCount(), 'active_accesses' => $product->activeAccessCount(), ],);Код виглядає "правильно доменно", але в нього швидко з’являються проблеми.
Щоб показати список, доводиться завантажувати багато стану, який не потрібен для жодного правила. З’являються зайві relation, підрахунки, N+1 або складні способи попереднього завантаження.
Ще гірше, якщо ми починаємо додавати в entity методи лише тому, що конкретна таблиця на екрані хоче ще одну колонку.
Для списку я дозволяю собі окрему модель читання
final readonly class ProductListItem{ public function __construct( public int $id, public string $name, public string $status, public int $registrationsCount, public int $activeAccessCount, ) { }}Цей об’єкт не повинен знати, чи можна активувати Product, які переходи стану дозволені або які інваріанти треба захистити.
Його відповідальність значно простіша: передати дані, потрібні конкретному читанню.
Для мене це і є Read Model, або модель читання.
Read Model можна побудувати прямим запитом
$rows = DB::table('products') ->leftJoin( 'registrations', 'registrations.product_id', '=', 'products.id', ) ->select([ 'products.id', 'products.name', 'products.status', ]) ->selectRaw( 'COUNT(registrations.id) AS registrations_count' ) ->groupBy([Такий запит не відтворює domain aggregate і не намагається це робити.
Він просто читає проєкцію даних.
Якщо таблиця потребує фільтра за status, сортування за name і кількості registrations, запит може бути написаний саме під ці потреби.
Це не порушує DDD
Для мене це стало важливим усвідомленням.
DDD не вимагає, щоб кожен SELECT проходив через entity. Воно допомагає моделювати предметну область і захищати важливі правила.
У першій статті про DDD і момент, коли CRUD уже недостатньо я якраз починала з цієї думки: DDD стає потрібним там, де система повинна захищати поведінку, а не просто зберігати дані.
Read Model не обходить ці правила, якщо він тільки читає і не використовується як спосіб змінити доменний стан.
Проблема починається, якщо модель читання починає змінювати стан
Безпечні операції: Модель читання (Read Model) | +-- відображення (display) +-- фільтрація (filter) +-- сортування (sort) +-- експорт (export) Небезпечні операції: Модель читання (Read Model) | +-- оновлення статусу (update status) +-- застосування доменних правил (enforce domain rule)Якщо DTO або query result починає отримувати метод activate(), ми вже змішуємо дві моделі.
Команди, які змінюють стан, повинні знову пройти через доменний шлях і правила aggregate.
Для мене це і є головна межа: читати можна з оптимальної проєкції, змінювати треба через модель, яка захищає інваріанти.
API-відповідь теж не зобов’язана повторювати aggregate
{ "id": 42, "name": "Example", "status": "active", "registrations_count": 18, "active_access_count": 7}Клієнту не потрібно знати внутрішню структуру aggregate.
Навіть навпаки: якщо API один в один повторює доменну модель, зовнішній контракт починає залежати від внутрішнього способу моделювання.
Окрема модель читання дозволяє дати клієнту стабільну і зручну форму даних.
Один домен може мати кілька моделей читання
Один і той самий Product domain Список в адмін-панелі:ProductListItem Дашборд:ProductSummary API:ProductDetailsResponse Експорт:ProductExportRow Одне й те саме джерело даних.Це ще один момент, який раніше здавався мені "дублюванням".
Але Product у списку, Product у dashboard і Product в export відповідають на різні питання.
Нам не обов’язково створювати один універсальний об’єкт з усіма можливими полями лише для того, щоб уникнути кількох маленьких моделей читання.
Для читання я використовую окремий query service або query object
final class FindProductsForAdminTable{ public function handle( ProductFilters $filters, ): LengthAwarePaginator { return DB::table('products') ->when( $filters->status, fn ($query, $status) => $query->where( 'status', $status, ), ) ->orderBy( $filters->sortBy,Цей клас не є domain service.
Він не приймає доменних рішень. Він просто відповідає на питання читання.
Для мене це корисне розділення: command/application service координує зміну стану, а query service повертає дані для читання.
Application Service і Query Service мають різну мету
У матеріалі про Application Service і Domain Service я писала, що Application Service координує сценарій зміни: транзакцію, repository, зовнішні залежності та виклик доменної поведінки.
Query Service має значно простішу мету: отримати потрібну проєкцію даних.
Йому не потрібна транзакція зміни стану, якщо він просто читає.
Read Model часто дає простіший і швидший запит
Я не створюю Read Model лише заради продуктивності, але продуктивність часто стає приємним наслідком.
Для списку можна вибрати тільки потрібні колонки, зробити агрегати в SQL, застосувати pagination до готового запиту і не завантажувати повну поведінкову модель.
Особливо це помітно в адміністративних таблицях і аналітичних списках, де читання значно багатше за одну сутність.
У AVN Center ця різниця стала практичною дуже швидко
У AVN Center ця тема з’явилася природно, коли поруч із правилами зміни стану почали з’являтися списки, статусні представлення та дані для різних екранів.
Я не хотіла змушувати domain model знати, як виглядає адміністративна таблиця.
Так само я не хотіла, щоб структура таблиці почала диктувати форму domain model.
Це продовжує думку з матеріалі про межі домену в системі, що росте: межі стають зрозумілішими, коли ми дивимося на відповідальність, а не просто на близькість даних.
Окремий Read Model не означає, що треба будувати повний CQRS
Це теж допомогло мені не ускладнювати рішення.
Можна мати окремий query object і DTO для читання, але використовувати ту саму базу даних і той самий застосунок.
Не обов’язково одразу вводити окреме сховище, асинхронні проєкції або складну інфраструктуру.
Я додаю складність тільки тоді, коли для неї з’являється реальна причина.
Коли я обираю окрему модель читання
Я дивлюся на кілька сигналів:
- Екрану потрібна лише частина aggregate.
- Список потребує даних із кількох таблиць.
- Потрібні підрахунки, сортування або фільтри, які не є доменною поведінкою.
- API має власну стабільну форму відповіді.
- Завантаження domain model створює зайві relation або N+1.
- У domain entity починають з’являтися методи лише заради конкретного екрану.
- Читання нічого не змінює і не повинно захищати інваріант.
Domain Model захищає правила, Read Model відповідає на запитання читання
Для мене головна помилка була в припущенні, що сильна domain model повинна бути єдиною формою даних у всій системі.
Насправді її цінність в іншому: вона захищає правила і допустимі зміни стану.
Read Model має іншу роботу. Він дає конкретному екрану, таблиці, export або API саме ті дані, які потрібні для читання.
Я не обходжу DDD, коли читаю напряму. Я обходжу домен лише тоді, коли намагаюся змінити його стан, не пройшовши через його правила.
Не кожен SELECT повинен відтворювати aggregate. Для читання я вибираю форму даних, яка відповідає на запит, а для зміни стану повертаюся до моделі, що захищає інваріанти.



