Modular Monolith + Hexagonal + DDD + EDA + CQRS
src/Blog/Article
├── Application
│ ├── Dto
│ ├── Repository
│ ├── Service
│ ├── Transformer
│ └── UseCase
├── Domain
│ ├── Entity
│ ├── Event
│ ├── Exception
│ ├── Repository
│ ├── Type
│ ├── ValueObject
│ └── config
└── Infrastructure
├── Controller
└── RepositoryДля побудови, встановлення залежностей та запуску проекту виконайте:
make initМодуль для взаємодії з користувачем, основна entity всього бізнесу. У різних контекстах користувач може називатися по-різному (e.g., автор, покупець, клієнт).
Однак усі ці назви - просто аліаси до користувача. Якщо іншим модулям потрібно отримати інформацію про користувача або виконати будь-яку дію з ним, вони повинні звертатися до цього контексту.
Наприклад: Article використовує root aggregate id юзера під назвою AuthorId.
Модуль для взаємодії зі статтею, основний модуль бізнесу. Він є кластером, і всі його entity повинні вважатися єдиним цілим. Цей кластер має один і тільки один root aggregate - Article, який містить в собі entity Comment. Коментар не може бути окремим модулем або root aggregate, бо він не може існувати без Article.
Щоб уникнути порушення бізнес-invariant та забезпечити атомарність, розробник повинен дістати root aggregate і викликати метод додавання на Article і зберегти в одній транзакції.
Use Cases:
@seeAdd Comment Handler
@seeDuplicate Article Handler
Для отримання інформації об агрегатах з інших BC's в Consumer модулі створюється класс репозиторію який може діставати данні з будь-якого контексту. Так як у нас зараз моноліт, для пришвидшення розробки ми достаємо данні з Shared Database і для того, щоб залишити можливіть лекого переходу на мікросервісну архітектуру, ми всі репозиторії імплеменуємо через загальний інтерфейс який повертає DTO, з яким вже працюють Queries та Handlers.
Модуль категорії не є кластером, оскільки він має тільки один root aggregate - Category. Категорія може існувати самостійно, навіть без жодної статті. Також ми інкапсулюємо всю логіку взаємодії з цим агрегатом.
Так само як і категорія, секція має тільки один root aggregate - Section. Однак, стаття може не належати до жодної секції.
Цей модуль не належить до бізнесу і не несе ніякої бізнес-цінності. Це набір загальних об'єктів, які може використовувати будь-який модуль у контексті Blog.
Наприклад, модулю Article потрібно дізнатися, чи існує категорія, id якої він отримав через API. Це можливо за допомогою провайдерів.
Use Cases:
@seeCategory Id Provider, Section Id Provider
Також в Shared знаходяться aggregate root id всіх модулів в контексті Blog, взявши звідти id модуль-споживач отримує можливість використати aggregate root id, при цьому не торкнувшись доменного слою модуля.
Цей контекст може бути названий, як
Mediaу майбутньому. Зачача - займатися менеджментом медіа всього сайту (e.g. баннери, відео, колекції картинок). Та забезпечувати хостинг картинок, генерацію URL, стиснення, перетворення форматів (jpg -> webp), створення різних розширень картинок для різних пристроїв. Може містити в собі модулі: ImageCollection, Video і т.д. Поки ми назвемо цей контекстImage, в якому знаходиться модульImage.
Фронтенд звертається до модуля Image для того, щоб upload картинку. Задача модуля - зберегти картинку в будь-яку файлову систему, та створити посилання в бд (при цьому в стовпчику is_used поставити false). Якщо картинка не використовується жодним іншим модулем (is_used = false), скрипт щотиждня проходиться та видаляє їх з бд та файлової системи.
Модуль використовує event subscriber для оповіщення про використання картинки.
Use Case:
@seeOn Article Created Event Subscriber
Після отримання івенту про створення статті від модуля Article контексту Blog, Event Subscriber отримує інформацію яка картинка використалася в статті і закидуює комманду на зміну статусу в Message Bus.
Наразі
Message Busпрацює в синхронному режимі, але в майбутньому можливо включити в асинхроний режим, де всі об'экти типуСommandбудуть серіалізовані та закинуті в чергу, наприкладRabbitMQ,Kafka. ДаліMessage Brokerбуде брати з черги певну кількість комманд та розкидувати між воркерами, поки ці комманди не будуть опрацьованіHandler.
В майбутньому, якщо сайт буде розвиватись і буде створений інший контекст
Merch,Shopабо щось інше. І буде бізнес потреба зробити пошук, то в контекстіSearchповинен створитися модульMerch, який вже буде відображати пошук саме в цьому контексті не торкаючись інших, вже існуючих.
Цей модуль відображає пошукову систему Bounded Context - Blog. Тут може використовуватись redis, elasticsearch, або щось інше. І найголовніше те, що контекст Blog навіть не знає який рушій використовує пошук, ба більше, він не навіть знає, що у користувачів є можливість шукати блоги в пошуку.
Цей модуль підписаний на івент Article Created Event. І все що йому потрібне, це
article titleтаauthor nameі найголовніше - це унікальний ідентифікатор самої статті, в результаті пошуку цей модуль поверне ключ за допомогою якого фронт вже сходить до модулюArticleі забере повну інформацію, щоб відрендерити сторінку.
Проксі та збірник коду та контрактів, який можуть використовувати модулі будь-яких контекстів. В майбутньмому при переході на мікросервіси, з нього буде створена composer бібліотека.
AggregateRoot - Клас, який дозволяє своїм підклассам (root aggregate) після наслідування, створювати та пуллити доменні івенти.
DomainEventInterface - Інтерфейс доменного івенту, який можуть створювати тільки root aggregate.
AggregateRootId - Клас, який дозволяє своїм підклассам після наслідування, виконувати роль VO-посилання на будь-який root aggregate. Тим самим дозволяючи агрегату в одному контексті використовувати та сворювати аліаси на агрегат іншого контексту.
Use Cases:
@seeUser Value Object - абстракнтий класс який наслідує Aggregate Root Id. І тепер модульUserконтекстуAuthвикористовується в модуліArticleконтекстуBlogпід аліасом AuthorId в Root Aggregate Article
P.S. Всі аліаси, створені з цих VO, позбавлені можливості генерувати uuid.
Переваги:
- Кожен контекст може мати свою группу розробників, які будуть розмовляти та писати код на одному
Ubiquitous Language. Це дозволить розуміти експертів в області та швидше розуміти замовників та робитиonboarding. Бо не буде такого, що на сайті в блозі автор називаєтьсяAuthor, а в коді -UserабоCustomer. - Кожна группа працює над одним контекстом, при цьому не торкаючись інших контекстів над якими працюють інші розробники.
- Можливість легкого переходу на мікросервіси.
Недоліки:
- Високий поріг входження.
- Повільна стартова швидкість розробки.
- Переускладнені зв'язки між різніми сутностями, та різні блокуючі фактори, наприклад один модуль не може напряму використовувати інший.
Всі операції на агрегаті повинні бути атомарні і гарантувати, що після збереження агрегату всі його частини не будуть inconsistency. Тож агрегати повинні притримуватись ACID:
A - Atomic
Всі зміни кластеру агрегату повинні відбуватися через root aggregate. Наприклад додаванняCommentдоArticleповинно відбуватися черезArticleтільки через нього. І збереження агрегату повинно відбутися в рамках однієї транзакції, не повинно бути такого частинного збереження - все або нічного.
C - Consistency
Описує, що агрегат може мати в собі посилання на інші root aggregates та сам агрегат може використовуватись в інших root aggregates.
Тому всі зміни в агрегат мають відбуватися в рамках однієї транзакції. Це означає, наприклад при видаленніArticleпотрібно щоб в модулі пошуку та картинок видалилися записи пов'язані з ним.
Але з цього виходить інша проблема, для того, щоб зберегти весь агрегат в одній транзакції - потрібно завантажити всі його частини в пам'ять, зробити зміну, та зберегти в одній транзакції. І це призведе до дуже великого агрегату, який буде налазити на іншіBounded Contexts, притягне залежності в модуль-ініціатора.
Для цього можна використатиMessage BusтаEvent Bus, використання цих компонентів дозволить достигнути consistency данних не відразу, але в кінці ця consistency буде достигнута. Може пройти 1 мілісекунда, година, але данні між агрегатами будуть consistency через деякий час. АлеEvent BusтаMessage Busсамі по собі не можуть гарантувати 100% consistency, для цього існують патерни 2PC та SAGA, які зі своїми перевагами та недоліками можуть гарантувати це.
I - Isolation
Може відбутися так ситуація, що одночасно прийло 2 запити на зміну аггрегату. 1 запит зберіг данні раніше 2 запиту, а 2 запит перезаписав данні 1 запиту. Це все призводить до inconsistency даних. Щоб це пофіксити можна в таблиці тримати версію даних і перед записом до бд перевіряти чи версія при доставанні даних = версії в бд при зберіганні, цей спосіб зазвичай використовують бд по типу MongoDB. Для MySQL та PostgreSQL існують транзакції різного рівня.
D - Durability
Правило описує, що данні повинні бути довговічними та не пропали при збої системи. Це досягається за допомогою бекапів бд.
Вся API документація реалізована за допомогою swagger-php та доступна тут