Привет, Хабр! В предыдущей статье мы обсудили стратегические паттерны, а теперь давайте углубимся в тактические. Важно помнить: в DDD тактика без стратегии теряет смысл! Если вы не знаете, как правильно разделить систему, отдел или предприятие на контексты и поддомены, ваши усилия, направленные на тактические паттерны, вряд ли принесут плоды. Стратегическое мышление в сочетании с тактическими подходами поможет создать эффективную и гибкую архитектуру, способную справляться с изменениями и требованиями бизнеса.
В этой статье мы рассмотрим реализацию шаблонов на основе вымышленного домена, что позволит лучше понять их применение в реальных сценариях. Вы можете ознакомиться с примерами реализации на GitHub (ссылки для TypeScript и Go).
Использование DDD на практике
Если вы столкнулись с большой и неповоротливой системой, следуйте этому плану:
Проводим Event Storming
Пригласите бизнес-заказчиков на встречу, чтобы прояснить требования и разбить систему на контексты. Рекомендую тщательно ознакомиться с процессом Event Storming, так как в этой статье я не буду углубляться в детали.
Результатом встречи станет полное понимание системы и её контекстов (подробнее о контекстах можно прочитать здесь).
Для нашего вымышленного домена контексты могут выглядеть следующим образом:
-
Warehouse
— контекст склада внутри маркетплейса -
Accounting
— контекст бухгалтерии внутри маркетплейса -
Delivery
— контекст доставки внутри маркетплейса
Ищем поддомены
Теперь, когда контексты определены, важно понимать, что они могут быть весьма обширными. Например, контекст складских операций может охватывать множество внутренних систем, каждая из которых может иметь свою сложную структуру.
Предлагаю в каждом контексте выделить поддомены и определить их типы, что станет основой для использования различных тактических паттернов, о которых мы поговорим в этой статье.
Для контекста Warehouse:
-
OrderManagement(Core)
— управление заказами на складе -
Location(Supporting)
— управление расположением товаров на складе
Контекст Accounting включает:
-
Reports(Core)
— генерация отчетов по финансам -
Verification(Supporting)
— проверка заказов и выставление накладных
Контекст Delivery представлен следующими поддоменами:
-
Core.Board(Core)
— доска предложений заказов -
Core.Couriers(Core)
— управление курьерами -
Supporting.Tracking(Supporting)
— отслеживание статуса доставки
Встраивание тактических паттернов в поддомены
Каждый поддомен внутри контекста имеет свою важность и уровень сложности, что требует применения соответствующих паттернов. Одни паттерны лучше подходят для простых поддоменов, другие — для более сложных. Важно использовать их по назначению и не привязываться к одному шаблону, чтобы избежать его применения в неподходящих ситуациях.
Основные тактические паттерны
Transaction Script
Представьте, что вы разрабатываете сервис авторизации. Насколько сложной может стать его бизнес-логика? Оправдано ли добавление архитектурно сложных решений в этот сервис? Рассмотрим следующий код:
export const register = async (req: Request, res: Response) => { const { email, password } = req.body; try { const existingUser = await User.findOne({ email }); if (existingUser) { return res.status(400).json({ message: 'User already exists' }); } const hashedPassword = await bcrypt.hash(password, 10); const newUser = new User({ email, password: hashedPassword }); await newUser.save(); res.status(201).json({ message: 'User registered successfully' }); } catch (error) { res.status(500).json({ message: 'Server error', error }); } }; export const login = async (req: Request, res: Response) => { const { email, password } = req.body; try { const user = await User.findOne({ email }); if (!user) { return res.status(400).json({ message: 'Invalid credentials' }); } const isMatch = await bcrypt.compare(password, user.password); if (!isMatch) { return res.status(400).json({ message: 'Invalid credentials' }); } const token = jwt.sign({ id: user._id }, JWT_SECRET, { expiresIn: '1h' }); res.json({ token }); } catch (error) { res.status(500).json({ message: 'Server error', error }); } };
Перед нами пример паттерна Transaction Script. Суть этого шаблона заключается в том, что мы организуем бизнес-логику с помощью процедур, каждая из которых обрабатывает один запрос из представления. Проще говоря, Transaction Script — это когда вся бизнес-логика сосредоточена в слое приложения (или сервисов). Хотя процедурный стиль может показаться устаревшим (адепты доменной модели могут критиковать его за анемию), он отлично подходит для простых задач, таких как авторизация.
Сервис авторизации является отличным примером Generic поддомена, где использование паттерна Transaction Script вполне оправдано. Не стесняйтесь применять этот подход и в Supporting поддоменных, где сложность задач не требует излишней архитектурной нагрузки.
Active Record
Следующий по сложности паттерн, который стоит рассмотреть, — это Active Record. Суть этого паттерна заключается в том, что бизнес-логика, подобно паттерну Transaction Script, располагается в сервисном слое, но значительная часть этой логики может быть интегрирована в модели ORM. При этом мы помещаем в ORM модели только ту логику, которая не содержит инфраструктурных зависимостей. Рассмотрим пример:
export class VerificationService { constructor( private readonly verificationRepository: Repository<Verification>, ) {} async update( updateVerificationDto: UpdateVerificationDto, ): Promise<Verification> { const verification = await this.verificationRepository.findOne({ where: { id: updateVerificationDto.id, }, }); if (verification === null) { throw new BadRequestException( `Verification with id ${updateVerificationDto.id} not found`, ); } if (updateVerificationDto.signed) { verification.signReport(); } if (updateVerificationDto.completed) { verification.completeVerification(); } return this.verificationRepository.save(verification); } } export class Verification { @PrimaryGeneratedColumn('uuid') id: string; /// ... columns signReport() { if (this.completed) { throw new Error('Cannot sign a report that has already been completed.'); } this.signed = true; } completeVerification() { if (!this.signed) { throw new Error( 'Cannot complete verification without signing the report.', ); } if (this.reportNumber < 0) { throw new Error('Report number cannot be negative.'); } this.completed = true; } }
В этом примере модель ORM избавляется от анемичности, и код становится более структурированным и выразительным. К сожалению, вокруг Active Record существует много незаслуженной критики. Некоторые считают его антипаттерном, однако важно отметить, что в методах модели должна находиться только чистая бизнес-логика. Пожалуйста, избегайте обращения к базе данных в этих бизнес-методах — тогда ваш Active Record никогда не превратится в антипаттерн.
Active Record является отличным компромиссом между доменной моделью (о которой мы поговорим позже) и Transaction Script. Этот паттерн хорошо подходит как для Supporting, так и для Generic поддоменов. Не пренебрегайте им!
Domain model
Domain Model — это ключевой аспект тактического DDD. Этот шаблон хорошо подходит для множества Сore поддоменов, где критически важно обеспечить качество и скорость внесения изменений.
Entity
Основой шаблона доменной модели является использование Entity с чистой бизнес-логикой. В отличие от Active Record, бизнес-логика здесь не размещается в ORM моделях, а инкапсулируется в отдельных чистых классах (сущностях). Добавляя поведение в сущности, мы превращаем модель из анемичной в полноценную, а уход от ORM слоя помогает избавиться от ненужных инфраструктурных зависимостей. Рассмотрим пример кода:
export class CurierEntity { id: string name: string orders: OrderEntity[] addOrder(newOrder) { if (this.isActicve === true) { if (this.rating > 4) { this.order.push(order) const totalRating = this.rating * this.orders.length; const updatedRating = (totalRating + 0.1) / (this.orders.length + 1); this.rating = updatedRating; } } } } export class OrderEntity { id: string name: string curier: CurierEntity; create(newOrder) { /// } }
Сервисный слой при этом будет тонким, так как основная часть логики сосредоточена в сущностях. Мы извлекаем сущности из базы данных и сохраняем их целиком:
export class CurierService { async addOrder(id, order) { curier = await this.repository.findById(id) curier.addOrder(new OrderEntity({...order})) await this.repository.save(curier) } }
Репозиторий представлен ниже. Как видите, вся «магия» с ORM и маппингом на сущности происходит именно здесь:
export class CurierRepository { findById(curierId): CurierEntity { const curierOrm = await this.prisma.cureir.findById(curierId) return CurierMapper.mapToDomain(curierOrm) } save(curier: CurierEntity): CurierEntity { const curierOrm = CurierMapper.mapToORM(curier) const updatedCurier = await this.prisma.curier.save(curierOrm) return curierMapper.mapToDomain(updatedCurier) } }
Используя доменные сущности, мы получаем множество преимуществ:
-
Не зависим от хранения данных: Нам не важно, как данные хранятся в базе.
-
Четкая ответственность: Ответственность за управление информацией лежит на тех, кто владеет всей необходимой информацией.
-
Реляционное представление: Мы можем строить наши сущности в соответствии с реляционными принципами.
-
Упрощенное тестирование: Меньше необходимости в моках, что делает тесты проще и надежнее.
-
Отказ от database-driven development: Мы переходим к более умному моделированию, сфокусированному на бизнес-логике.
-
Аккуратный сервисный слой: Получаем ясный и понятный сервисный слой, что упрощает сопровождение кода.
Агрегат
Одних Entity недостаточно. Хотя сущности отлично инкапсулируют бизнес-логику, возникает вопрос: как их связывать между собой? Как установить четкие модульные границы и обеспечить транзакционную согласованность?
Если сущности неправильно объединены, бизнес-правила могут теряться. Рассмотрим наглядный пример. Представим, что из-за ограничения по лимиту мы не можем добавить новый заказ на склад.
export class WarehouseEntity { addOrder(order: OrderEntity) { if (this.orders.length > 500) { throw new Error('Limit 500'); } this.orders.push(order); } } export class Curier { addOrder(curierId, newOrder) { const curier = curierRepository.findById(warehouseId) curier.addOrder(new OrderEntity(...newOrder)) return curierRepository.save(curier) } }
Этот код должен работать, но что если в другой части системы кто-то решил добавить заказ, минуя WarehouseEntity
?
export class OrdersService { reorder(curierId, oldOrder) { const order = new OrderEntity({...oldOrder, curierId}) return ordersRepository.save(order) } }
Вуаля! Такой баг сложно отловить — полагаться придется на тестирование или, что хуже, на отличную память коллег. В худшем случае функционал одной части системы может повредить другую. Люди должны постоянно помнить о всех проверках внутри сущностей и учитывать их при разработке новых функций. Чтобы чтобы избежать такой несогласованности, нам необходимы абстракции для управления границами сущностей. К счастью, такая абстракция существует.
Агрегат — это иерархия сущностей, помогающая сохранить бизнес-правила и обеспечить транзакционную согласованность. Если выделен агрегат Courier
, изменяйте его только через корень. Никаких манипуляций с Order
напрямую — только через родителя.
Преимущества аггрегатов:
-
Легкость тестирования: В агрегате сосредоточена чистая бизнес-логика, что упрощает процесс тестирования.
-
Простой интерфейс: Правильные агрегаты предоставляют четкий и ясный интерфейс, скрывая сложность под капотом.
-
Целостность: Агрегат является цельным блоком, который мы можем извлечь, изменить и сохранить. Можно сказать, что агрегат — это основа вашего будущего модуля.
Таким образом, использование агрегатов помогает избежать многих проблем, связанных с согласованностью и управлением бизнес-правилами, обеспечивая при этом четкость и структурированность кода.
Агрегат и модульность
Довольно часто встречаются советы о том, что следует группировать сущности в агрегаты по принципу 1 к 1 (одна сущность — один агрегат). Аргументируют это тем, что сложно правильно разделить систему на агрегаты, поэтому проще сразу достичь максимальной гранулярности. Это вредно и неправильно; никогда так не делайте.
Важно осознать ценность агрегатов. Как я упоминал ранее, агрегат может стать основой модульности, и само понятие агрегата во многом пересекается с модульностью. Агрегат — это самостоятельная единица, обладающая глубиной и относительной независимостью от внешних компонентов. Один агрегат «владеет» и управляет данными, которые находятся под его контролем. Ничто не дает вам права изменять эти данные каким-либо образом извне; только агрегат отвечает за это.
Модуль, в свою очередь, также должен инкапсулировать определённый функционал таким образом, чтобы в будущем его можно было легко отделить и превратить в самостоятельную единицу развертывания. Важно достичь хорошей инкапсуляции внутри модуля, чтобы он был по-настоящему качественным и полезным. Я вижу много общего между понятиями агрегата и модуля!
Пожалуйста, старайтесь проектировать агрегаты с учетом глубины и обеспечивать узкий и простой интерфейс для взаимодействия с ними. Это не только улучшит структуру вашего кода, но и облегчит его понимание и поддержку.
Чересчур большие агрегаты
Представьте, что у нас есть курьеры, у которых есть заказы, в заказах содержатся товары, а в позициях — еще что-то. Цепочка может тянуться бесконечно.
Извлекать из базы огромные массивы данных для выполнения небольших обновлений крайне неэффективно. Разделять агрегаты по одной сущности, как иногда советуют, — это больше похоже на инженерную катастрофу. Важно найти баланс.
Для этого необходимо проанализировать бизнес-процессы, задавать вопросы экспертам и искать eventual consistency между сущностями, которые могут указать на слабую согласованность. Если строгая согласованность (ACID) не критична для операций между сущностями и таких взаимодействий не так уж много, это может указывать на слабую связность.
Например, как часто вам нужно обновлять документацию по заказу при работе с курьерами? Скорее всего, это происходит не так часто. Так зачем же тянуть документы в агрегат курьера?
Для таких редких взаимодействий лучше всего подойдет обмен сообщениями. При выполнении бизнес-операции просто добавляйте новое сообщение в массив messages
внутри агрегата:
crashOrder(orderId: string) { const order = this.orders.find((el) => el.Id === orderId); order.changeStatus(false); this.messages.push( new OrderCrashedEvent({ aggregateId: this.id, payload: { orderId: order.Id, }, }), ); }
На стороне репозитория вы можете извлекать добавленные сообщения из сущности и отправлять их в брокер сообщений (или в базу данных, а затем в брокер, если используете паттерн transactional outbox):
async saveCurier(curier: CurierEntity): Promise<CurierEntity> { const curierORM = CurierMapper.mapToORM(curier); const outboxORM = warehouse.pullMessages() const crOrm = await this.dataSource.transaction( async (transactionalEntityManager) => { await transactionalEntityManager.save(outboxORM); return await transactionalEntityManager.save(curierORM); }, ); return CurierMapper.mapToDomain(crOrm); }
Однако никто не запрещает вам реализовать обмен сообщениями другим способом. Универсальных решений не существует. Главное — понимать принципы и мотивы, стоящие за теми или иными решениями.
Старайтесь избегать ситуаций, когда необходимо обновлять несколько агрегатов в одной ACID-транзакции. Если это становится частым случаем, пересмотрите границы агрегатов. Возможно, они у вас проведены неправильно.
Value Objects
В Domain-Driven Design (DDD) Value Objects представляют собой концепцию, которая добавляет ценность за счёт акцента на сущностных характеристиках объекта, а не на его уникальной идентичности. Эти объекты не имеют идентификаторов, но могут инкапсулировать данные и поведение, связанное с ними. Value Objects неизменяемы и определяются исключительно своими атрибутами, что делает их идеальными для моделирования понятий, таких как деньги, даты или адреса.
export class AmountObjectValue { public amount: number; public rate: number; constructor(attributes: Attributes) { this.amount = attributes.amount; this.rate = attributes.rate; } applyDiscount(discount: number): number { return this.amount * discount; } getAmoutWithoutTax(): number { return this.amount * (100 - this.rate); } differenceAfterTax(): number { return this.amount - this.getAmoutWithoutTax(); } }
Чаще используйте Value Objects, особенно когда в них можно скрыть значительную бизнес-логику, обеспечить необходимую инкапсуляцию и снизить избыточную сложность в Entity.
Read Model
При разработке агрегатов следует помнить, что они необходимы только для операций изменения данных. Если требуется только чтение без изменений, лучше использовать паттерн Read Model.
export class ReportReadModel { readonly id: string; readonly isValid: boolean; readonly orderId: string; readonly reportNumber: number; readonly positions: ReportPositionReadModel[]; constructor(attributes) { this.id = attributes.id; this.isValid = attributes.isValid; this.orderId = attributes.orderId; this.reportNumber = attributes.reportNumber; this.positions = attributes.positions; } }
У вас может быть множество моделей чтения для различных сценариев — не бойтесь создавать их по необходимости. Главное правило: не используйте их для внесения изменений, так как за все изменения отвечают агрегаты.
Вполне вероятно (и, скорее всего, так и будет), что модель чтения будет содержать данные из разных модулей. Это не проблема, поскольку она используется исключительно для чтения. Когда мы говорим о модульности и границах модуля, ключевое внимание уделяется именно операциям изменения данных.
Тестирование
-
Transaction Script: Этот паттерн содержит минимум бизнес-логики, поэтому для него подходит стратегия обратной пирамиды тестирования (Reversed Testing Pyramid), где основной акцент делается на end-to-end тестах, а не на юнит-тестах.
-
Active Record: Здесь сложность бизнес-логики выше, поэтому end-to-end или ручные тесты могут оказаться слишком затратными. Для этого паттерна лучше подойдет тестовый ромб (Testing Diamond), где основное внимание уделяется интеграционным тестам при поддержке умеренного количества юнит и e2e-тестов.
-
Domain model: Высокая сложность и большое количество бизнес-правил, поэтому наиболее эффективной будет пирамида тестирования (Testing Pyramid), с широким основанием из юнит-тестов, дополняемым интеграционными и e2e-тестами.
Некоторый итог
Итак, мы прошлись по основным шаблонам применили тактические паттерны, целевая картинка нашей системы выглядит так.
Для контекста Warehouse:
-
OrderManagement(Core)
— Domain Model -
Location(Supporting)
— Transaction script
Контекст Accounting состоит из:
-
Reports(Core)
— Domain Model -
Verification(Supporting)
— Active record
Контекст Delivery представлен тремя контекстами:
-
Core.Board(Core)
— Domain Model -
Core.Couriers(Core)
— Domain Model -
Supporting.Tracking(Supporting)
— Transaction script
Весь код нашего вымышленного домена можете посмотреть на github (Typescript, Golang). Не забывайте про стратегические паттерны, побольше внимания уделите прежде всего им. Используйте тактические паттерны там, где они действительно помогут, а не добавят лишней головной боли.
ссылка на оригинал статьи https://habr.com/ru/articles/854140/
Добавить комментарий