Привет, Хабр! В предыдущей статье мы обсудили стратегические паттерны, а теперь давайте углубимся в тактические. Важно помнить: в DDD тактика без стратегии теряет смысл! Если вы не знаете, как правильно разделить систему, отдел или предприятие на контексты и поддомены, ваши усилия, направленные на тактические паттерны, вряд ли принесут плоды. Стратегическое мышление в сочетании с тактическими подходами поможет создать эффективную и гибкую архитектуру, способную справляться с изменениями и требованиями бизнеса.

В этой статье мы рассмотрим реализацию шаблонов на основе вымышленного домена, что позволит лучше понять их применение в реальных сценариях. Вы можете ознакомиться с примерами реализации на GitHub (ссылки для TypeScript и Go).

Использование DDD на практике

Если вы столкнулись с большой и неповоротливой системой, следуйте этому плану:

Проводим Event Storming

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

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) - отслеживание статуса доставки

Contexts and Subdomains
Contexts and Subdomains

Встраивание тактических паттернов в поддомены

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

Основные тактические паттерны

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 тестах, а не на юнит-тестах.

Reversed testing pyramid
Reversed testing pyramid
  • Active Record: Здесь сложность бизнес-логики выше, поэтому end-to-end или ручные тесты могут оказаться слишком затратными. Для этого паттерна лучше подойдет тестовый ромб (Testing Diamond), где основное внимание уделяется интеграционным тестам при поддержке умеренного количества юнит и e2e-тестов.

Testing diamond
Testing diamond
  • Domain model: Высокая сложность и большое количество бизнес-правил, поэтому наиболее эффективной будет пирамида тестирования (Testing Pyramid), с широким основанием из юнит-тестов, дополняемым интеграционными и e2e-тестами.

Testing Pyramid
Testing Pyramid
Strategy for each type of subdomain
Strategy for each type of subdomain

Некоторый итог

Итак, мы прошлись по основным шаблонам применили тактические паттерны, целевая картинка нашей системы выглядит так.

Для контекста 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). Не забывайте про стратегические паттерны, побольше внимания уделите прежде всего им. Используйте тактические паттерны там, где они действительно помогут, а не добавят лишней головной боли.

Комментарии (7)


  1. nomhoi
    02.11.2024 03:40

    Вопрос по примеру из раздела Transaction Script, если возникнет необходимость написать утилиту командной строки с авторизацией, будете использовать эти же скрипты?
    Как выглядят тесты для проверки этих скриптов? К какому типу тестов они относятся?


    1. zhuravlevma Автор
      02.11.2024 03:40

      Спасибо за интерес, дополнил в статье пункт про тестирование с типами


  1. Bone
    02.11.2024 03:40

    Если у пользователя есть личный кабинет, в котором он может запросить счет на оплату. В своем кабинете пользователь видит свой запрос счета и может его отменить. Этот запрос должен попасть в личный кабинет бухгалтерии, они должны его обработать и результатом должно стать появление счета в кабинете клиента. Кто разбирается в DDD и не лень, можете описать кому в данном случае что принадлежит (например "запрос счета" принадлежит пользователю или бухгалтерии? А выставленный счет кому принадлежит?) И каким образом происходит взаимодействие (например, как запрос счета появляется в бухгалтерии после того как пользователь нажал на кнопку?).


    1. zhuravlevma Автор
      02.11.2024 03:40

      Например пользователь оформляет заявку на оплату счета. В моменте оформления эта заявка сохраняется в бд, ей ставится статус исполняется и кидается Event в сервис бухгалтерии.

      В сервисе бухгалтерии принимается данный event, создается в бд заявка для бухгалтеров. Они там чего-то считают, инфу заносят, документы прикрепляют. После того, как заявка готова, бухгалтер переводит ее в статус готово и новый Event улетает обратно в сервис пользователя, где той заявке проставляется статус готово и прикрепляется документ из бухгалтерии во вложения.

      То есть, у тебя есть два контекста (Пользовательский, бухгалетерский). В каждом контексте есть своя "Заявка" с разными полями, агрегатами и поведением. И жить эти контексты могут изолированно.


  1. TldrWiki
    02.11.2024 03:40

    Отличная статья чтобы освежить знания в выходные.


  1. xfg
    02.11.2024 03:40

    Примеры со сменой email-адреса некорректны, поскольку между выборкой и сохранением есть момент когда может произойти вставка данных из другого потока, проверка не сработает и получим двух пользователей с одинаковым email. Можно использовать pessimistic locking, но тогда это приведет к снижению производительности системы. Самый разумный вариант это уникальный индекс в хранилище данных по email, никаких проверок, меняем email и сохраняемся. Также обычно такие штуки

    await this.repository.findAll()

    возвращают не данные, а курсор по которому можно итерироваться и никаких утечек по памяти не будет.

    Вообще про трилемму это явно взято из блога Владимира Хорикова. У автора есть неплохие статьи, но эта точно не из их числа.


    1. zhuravlevma Автор
      02.11.2024 03:40

      спасибо за мысль, не думал про конкурентность, когда писал эту часть. можно было притянуть что проверка уникальности тоже будет проводиться на сервисном уровне и что полноты модели не будет, но вот пример тогда с findAll действительно бессмысленный и даже вредный. Эту часть решил выпилить, чтобы не плодить ересь :)