Как стать автором
Обновить

Анемичная модель предметной области и логика в сервисах

Уровень сложностиСредний
Время на прочтение20 мин
Количество просмотров6.3K

Анемичная модель предметной области (Anemic domain model) это такая модель, где сущности содержат только свойства, а бизнес-логика находится в сервисах. Ее противоположность это богатая модель предметной области (Rich domain model), где логика находится в сущностях, а cервиcы рекомендуют писать только в редких случаях.

В этой статье я хочу показать, почему логика в сервисах является более правильным подходом. Мы рассмотрим пример достаточно сложных бизнес-требований и их реализацию с Anemic domain model.

Для бизнес-логики нужны зависимости, а их сложно пробросить в сущность, которая загружается из базы в произвольный момент во время выполнения. Также с Rich domain model в сущность помещаются все изменяющие ее бизнес-действия. Это приводит к тому, что сущность превращается в God-object, и код получается более сложный в поддержке.

Например, есть сущность "Order" с полем "status". У заказа может быть несколько десятков статусов, и на каждый статус есть свой сценарий, который его устанавливает. Значит в сущности будет несколько десятков методов. И это только одно поле. У товара кроме собственных полей обычно есть изображения, и логику их изменения в этом подходе тоже надо помещать в сущность "Product", так как она является aggregate root.

В статье рассмотрено 6 бизнес-действий. Статья получилась довольно большая, но на меньших примерах, на мой взгляд, сложно показать задачи, которые возникают в реальной разработке.

Бизнес-требования

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

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

Пока товар на редактировании у поставщика, любые поля кроме названия необязательны для заполнения. При отправке на проверку должны быть заполнены категория, название, и описание не менее 300 символов.
Все ошибки валидации желательно возвращать вместе, а не по одной.
Отправка товара на проверку в другую систему осуществляется вызовом API. Надо хранить историю отправок на проверку в нашей базе.
Отправлять надо только после успешного сохранения данных в нашу базу. В данных надо отправлять старое и новое значение поля, чтобы показывать красивый diff в интерфейсе.
Другая система иногда работает нестабильно, поэтому если вызов API не удался, надо это отображать в статусе запроса. Например, после успешной отправки ставить статус "Отправлено". Потом запрос переотправляется вручную по кнопке в админке.
Очереди? Да, очереди в плане, команда, которая ими занимается, займется нашим проектом через 2 месяца.

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

Также есть функциональность массовой загрузки данных через CSV и массовой отправки на проверку в виде консольных фоновых задач. В рамках примера мы ее реализовывать не будем, но надо учитывать, что изменение данных одного товара может происходить одновременно в 2 разных процессах.

Запрос на проверку это отдельная сущность, в коде она обозначается названием "Review". Предполагается, что пользователь имеет к ним доступ и может отменять по желанию. Название "ревью" на русском можно считать краткой версией названия "запрос на проверку".

Реализацию можно посмотреть в репозитории.

Реализация

Сущности

Product:
id            int
user_id       int
category_id   int
name          string
description   string
status        int
created_at    string

ProductChange:
product_id    int
field_values  json

Category:
id            int
name          string

Review:
id            int
user_id       int
product_id    int
status        int
field_values  json
created_at    string
processed_at  string

Создание товара

<?php

namespace frontend\controllers;

class ProductController
{
  public function actionCreate(): Response
  {
    $form = new CreateProductForm();
    $form->load($this->request->post(), '');

    if (!$form->validate()) {
        return $this->validationErrorResponse($form->getErrors());
    }

    $product = $this->productService->create($form, $this->getCurrentUser());

    return $this->successResponse($product->toArray());
  }
}


namespace frontend\services;

class ProductService
{
  public function create(CreateProductForm $form, User $user): Product
  {
    $product = new Product();

    $product->user_id = $user->id;
    $product->status = ProductStatus::HIDDEN->value;
    $product->created_at = DateHelper::getCurrentDate();

    $product->category_id = null;
    $product->name = $form->name;
    $product->description = '';

    $this->productRepository->save($product);

    return $product;
  }
}

Тут все стандартно, входное DTO с правилами валидации и сервис, который его обрабатывает. При создании заполняется только поле "name". Дальше будет интереснее.

Сохранение товара

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

<?php

class ProductController
{
  public function actionSave(int $id): Response
  {
    $product = $this->findEntity($id, needLock: true);

    $validationResult = $this->productService->isEditAllowed($product);
    if ($validationResult->hasErrors()) {
        return $this->validationErrorResponse($validationResult->getErrors());
    }

    $form = new SaveProductForm();
    $form->load($this->request->post(), '');
    if (!$form->validate()) {
        return $this->validationErrorResponse($form->getErrors());
    }

    $product = $this->productService->save($validationResult, $form);

    return $this->successResponse($product->toArray());
  }

  private function findEntity(int $id, bool $needLock): Product
  {
    $product = $this->productRepository->findById($id, $needLock);

    if ($product === null) {
        throw new NotFoundHttpException('Entity not found');
    }

    $isAccessAllowed = $product->user_id === $this->getCurrentUser()->id;
    if (!$isAccessAllowed) {
        throw new ForbiddenHttpException('Access denied');
    }

    return $product;
  }
}

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

Локи

Для предотвращения одновременной обработки используются локи.

<?php

class ProductRepository
{
  public function findById(int $id, bool $needLock): ?Product
  {
    if ($needLock) {
        $this->lockService->lock(Product::class, $id);
    }

    /** @var ?Product $product */
    $product = Product::find()->where(['id' => $id])->one();

    return $product;
  }
}

Метод lock() конкатенирует название класса и id и вызывает MySQL-функцию GET_LOCK(:str, :timeout) (документация).

Это мьютекс, он работает так. Первый процесс запрашивает мьютекс с определенным именем, мьютекс помечается занятым. Второй при запросе мьютекса с тем же именем будет ждать, пока он не освободится, но не дольше, чем указано в "timeout".
Мьютекс, запрошенный этой функцией, при закрытии подключения к БД освобождается автоматически.

В коде примеров явного освобождения нет, так как в PHP подключение к БД закрывается после обработки запроса. Если освобождать явно, то это надо делать в контроллере после вызова сервиса. Раньше нельзя, так как бизнес-логика еще не завершилась, позже нет смысла, это лишь задержит другие процессы, которые собираются работать с этим объектом.

Иногда можно использовать SQL-оператор FOR UPDATE, но он работает только внутри транзакции, а обработка может быть долгой и использовать сетевые вызовы, или требовать 2 раздельные транзакции.

Блокировать лучше только aggregate roots, иначе с этим будет сложно работать. То есть например Order, а не OrderItem.

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

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

Причина, почему в веб-приложениях так обычно не делают, заключается в том, что часто есть только один источник изменений некоторого ресурса. Пользователь создал заказ, он его больше не меняет. Одна система обработала заказ, поменяла статус, запускается другая система, ставит свой статус, потом третья. Вместе с одним заказом они не работают. Ресурсы, которые может пользователь может редактировать, например статьи или настройки профиля, он обычно не редактирует одновременно. Поэтому обычно все работает без локов. А когда не работает, появляется баг, все говорят "ну так бывает", кто-то исправляет данные, и на этом всё заканчивается до следующего раза.

История про локи

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

Поставщик отправил на ревью несколько десятков тысяч товаров. Товары отправлялись по одному, поэтому обработка продолжалась 12 часов. Для запуска консольных задач у нас использовалась внутренняя очередь. Там был настроен таймаут выполнения одной задачи 6 часов и retry 1 раз. Поэтому через 6 часов задача запустилась еще раз.

Так как список товаров был тот же самый, SQL-запросы в ней были абсолютно одинаковые. Первая задача прогрела разные внутренние механизмы БД в обоих системах, поэтому во второй раз они выполнялись немного быстрее, чем в первый. Через пару часов вторая задача догнала первую, и получился классический race condition.

Первая проверяет, что товар не отправлен, значит можно отправлять; вторая проверяет, что товар не отправлен, значит можно отправлять.
Первая отправляет список изменений; вторая отправляет список изменений.
Первая помечает товар отправленным; вторая помечает товар отправленным.

Добавление локов для всех мест, где изменяется товар, решило эту проблему и другие аналогичные. Настройки очередей мы поменяли, но пользователь может и сам запустить отправку 2 раза.

Валидация

Валидация сущности Product при сохранении делается методом isEditAllowed.

<?php

class ProductService
{
  public function isEditAllowed(Product $product): ProductValidationResult
  {
    $productValidationResult = new ProductValidationResult($product);

    if ($product->status === ProductStatus::ON_REVIEW->value) {
        $productValidationResult->addError('status', 'Product is on review');
    }

    return $productValidationResult;
  }
}

Нам нужно возвращать описание ошибки в виде текста, чтобы показать пользователю в интерфейсе, результат в виде true/false тут не подходит. Описание ProductValidationResult будет далее.

Логика сохранения

<?php

class ProductService
{
  public function save(ProductValidationResult $productValidationResult, SaveProductForm $form): ProductChange
  {
    $product = $productValidationResult->getProduct();
    $productChange = $this->productChangeRepository->findById($product->id);

    if ($productChange === null) {
        $productChange = new ProductChange();
        $productChange->product_id = $product->id;
    }

    $fieldValues = [];
    if ($form->category_id !== $product->category_id) {
        $fieldValues['category_id'] = $form->category_id;
    }
    if ($form->name !== $product->name) {
        $fieldValues['name'] = $form->name;
    }
    if ($form->description !== $product->description) {
        $fieldValues['description'] = $form->description;
    }
    $productChange->field_values = $fieldValues;

    $this->productChangeRepository->save($productChange);

    return $productChange;
  }
}

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

Я решил использовать репозитории для всех сущностей и сущности без связей, как наиболее атомарный вариант, потому что организовать это можно по-разному. В реальном приложении в сущностях будут связи, и репозитории желательно делать только для агрегатов (aggregate roots).

Просмотр

<?php

class ProductController
{
  public function actionView(int $id): Response
  {
    $product = $this->findEntity($id, needLock: false);
    $product = $this->productService->view($product);

    return $this->successResponse($product->toArray());
  }
}


class ProductService
{
  public function view(Product $product): Product
  {
    $productChange = $this->productChangeRepository->findById($product->id);

    $this->applyChanges($product, $productChange);

    return $product;
  }

  private function applyChanges(Product $product, ?ProductChange $productChange): void
  {
    if ($productChange !== null) {
        foreach ($productChange->field_values as $field => $value) {
            $product->$field = $value;
        }
    }
  }
}

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

У кого-то может появиться мысль поместить метод applyChanges() в сущность. В реальном приложении в Product будет штук 30 полей, изображения, файлы с инструкциями, эта обработка будет занимать несколько сотен строк, поэтому вряд ли это подходящее решение. Можно сделать отдельный компонент, или репозиторий, который по findById() будет возвращать объект с примененными изменениями.

Отправка на ревью

<?php

class ProductController
{
  public function actionSendForReview(int $id): Response
  {
    $product = $this->findEntity($id, needLock: true);

    $productValidationResult = $this->productService->isSendForReviewAllowed($product);
    if ($productValidationResult->hasErrors()) {
        return $this->validationErrorResponse($productValidationResult->getErrors());
    }

    $review = $this->productService->sendForReview($productValidationResult, $this->getCurrentUser());

    return $this->successResponse($review->toArray());
  }
}


class ProductService
{
  public function isSendForReviewAllowed(Product $product): ProductValidationResult
  {
    $productChange = $this->productChangeRepository->findById($product->id);
    $validationResult = new ProductValidationResult($product, $productChange);

    $newProduct = clone $product;
    $this->applyChanges($newProduct, $productChange);

    if ($newProduct->status === ProductStatus::ON_REVIEW->value) {
        $validationResult->addError('status', 'Product is already on review');
    } elseif ($productChange === null) {
        $validationResult->addError('id', 'No changes to send');
    } else {
        if ($newProduct->category_id === null) {
            $validationResult->addError('category_id', 'Category is not set');
        }
        if ($newProduct->name === '') {
            $validationResult->addError('name', 'Name is not set');
        }
        if ($newProduct->description === '') {
            $validationResult->addError('description', 'Description is not set');
        }
        if (strlen($newProduct->description) < 300) {
            $validationResult->addError('description', 'Description is too small');
        }
    }

    return $validationResult;
  }
}

Начинаем с блокировки товара от изменений, потом делаем бизнес-проверки. Если пользователь случайно нажмет кнопку 2 раза, второй запрос будет ждать, пока завершится первый, и повторной отправки не будет.

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

Валидация сущности

<?php

class ProductService
{
  public function isSendForReviewAllowed(Product $product): ProductValidationResult
  {
      ...
  }

  public function sendForReview(ProductValidationResult $productValidationResult, User $user): Review
  {
      ...
  }
}


class ProductValidationResult
{
  ...

  public function __construct(?Product $product, ?ProductChange $productChange = null)
  {
    $this->product = $product;
    $this->productChange = $productChange;
  }

  public function addError(string $field, string $error): void
  {
    $this->product = null;
    $this->productChange = null;
    $this->errors[$field][] = $error;
  }

  public function hasErrors(): bool
  {
    return !empty($this->errors);
  }
  
  ...
}

ProductValidationResult нужен для передачи ошибок валидации сущности. Тут нет DTO c входными данными, которое можно провалидировать, сущность загружается из базы. Он хранит результат валидации и все загруженные данные, чтобы их не пришлось загружать в логике еще раз. Также он показывает другому программисту, что для вызова sendForReview() надо сначала сделать валидацию и получить ProductValidationResult. Если бы sendForReview() принимал Product, это было бы не так явно.

Логика отправки

Мы добрались до самого сложного метода.

<?php

class ProductService
{
  public function sendForReview(ProductValidationResult $productValidationResult, User $user): Review
  {
    $product = $productValidationResult->getProduct();
    $productChange = $productValidationResult->getProductChange();
    if ($productChange === null) {
        throw new RuntimeException('This should not happen');
    }

    $reviewFieldValues = $this->buildReviewFieldValues($product, $productChange);

    $review = new Review();
    $review->user_id = $user->id;
    $review->product_id = $product->id;
    $review->field_values = $reviewFieldValues;
    $review->status = ReviewStatus::CREATED->value;
    $review->created_at = DateHelper::getCurrentDate();
    $review->processed_at = null;

    $product->status = ProductStatus::ON_REVIEW;

    $transaction = $this->dbConnection->beginTransaction();
    $this->productRepository->save($product);
    $this->reviewRepository->save($review);
    $transaction->commit();

    $this->sendToAnotherSystem($review);

    $review->status = ReviewStatus::SENT->value;
    $this->reviewRepository->save($review);

    return $review;
  }

  private function buildReviewFieldValues(Product $product, ProductChange $productChange): array
  {
    $reviewFieldValues = [];
    $productFieldValues = $productChange->field_values;
    foreach ($productFieldValues as $key => $newValue) {
        $oldValue = $product->$key;
        $fieldChange = ['new' => $newValue, 'old' => $oldValue];
        $reviewFieldValues[$key] = $fieldChange;
    }

    return $reviewFieldValues;
  }

  private function sendToAnotherSystem(Review $review): void
  {
    $this->anotherSystemClient->sendReview($review);
  }

Нам нужно выполнить такие требования:
- Сохранить товар и ревью в нашу базу
- После успешного сохранения отправить ревью в другую систему
- После успешной отправки пометить ревью в нашей базе успешно отправленным

Мы хотим красиво показывать в интерфейсе что на что изменилось, поэтому надо записывать старое и новое значение.

Обратите внимание, отправка запроса к API это нетранзакционное взаимодействие, поэтому мы делаем сохранение данных в 2 шага - до отправки сохраняем данные с одним статусом в одной транзакции БД, отправляем данные, после отправки сохраняем с другим статусом в другой транзакции БД.

Если отправка в другую систему не удалась, объект Review останется в статусе CREATED, это можно будет отследить и скорректировать ошибку вручную. Например, показать в админке кнопку "Переотправить".

Именно вот эта логика "Сохранить - Отправить - Сохранить" на мой взгляд и является сложной для реализации в Rich domain model. У нас есть несколько строк подряд с вызовом сеттеров. Можно поместить их в сущность, но остальной код туда поместить нельзя, он должен быть где-то вне сущности. Делать коммиты транзакций БД это не ответственность сущности Product или Review. Для этого придется пробрасывать в сущность технические компоненты для работы с базой данных, что не соответствует назначению доменного слоя.

Бизнес-логика

А теперь важный момент. Это - не бизнес-логика.

<?php

class Review
{
  public function create(Product $product, ProductChange $productChange, User $user): void
  {
    $this->user_id = $user->id;
    $this->product_id = $product->id;
    $this->field_values = $this->buildReviewFieldValues($product, $productChange);
    $this->status = ReviewStatus::CREATED->value;
    $this->created_at = DateHelper::getCurrentDate();
    $this->processed_at = null;
  }
}

Это бизнес-логика.

class ProductService
{
  public function sendForReview(
    ProductValidationResult $validationResult,
    User $user,
  ): Review {
    [$product, productChange] =
      $this->getValidatedEntities($validationResult);

    // Сохранить товар и ревью в нашу базу
    $this->setFieldValues([$review, $product], $productChange, $user);
    $this->saveEntitiesInTransaction([$review, $product]);
    
    // После успешного сохранения отправить ревью в другую систему
    $this->sendToAnotherSystem($review);

    // После успешной отправки пометить ревью в нашей базе успешно отправленным
    $this->markAsSent($review);
    $this->saveEntitiesInTransaction([$review]);

    return $review;
  }
}

Код является прямым отражением бизнес-требований, записанных на естественном языке. То есть код содержит модель бизнес-требований.

Бизнес-логика - реализация правил и ограничений автоматизируемых операций.
бизнес-логика — это реализация предметной области в информационной системе.

Бизнес-логика это реализация бизнес-требований.

- Сохранить товар и ревью в нашу базу
- После успешного сохранения отправить ревью в другую систему
- После успешной отправки пометить ревью в нашей базе успешно отправленным

Обратите внимание, в бизнес-требованиях используются названия "товар" и "ревью". Если считать бизнес-требования описанием алгоритма действий, то эти названия являются обозначением переменных. Поэтому правильная программная модель бизнес-требований должна содержать переменные "product" и "review", а никакой не "this". Бизнес не обсуждает, как вы будете устанавливать значения свойств сущности.

DDD говорит, что можно делать сервисы с логикой когда используются несколько сущностей, например перевод денег с аккаунта на аккаунт. Одна сущность это частный случай N сущностей, поэтому сервисы для него тоже прекрасно подходят. Никакая логика не принадлежит сущности, логика устанавливает правила, как должны изменяться сущности, неважно сколько их участвует в конкретном бизнес-действии.

Принятие ревью

<?php

namespace internal_api\controllers;

class ReviewController
{
  public function actionAccept(int $id): Response
  {
    $review = $this->findEntity($id, needLock: true);

    $review = $this->reviewService->accept($review);

    return $this->successResponse($review->toArray());
  }
}


namespace internal_api\services;

class ReviewService
{
  public function accept(Review $review): Review
  {
    if ($review->status !== ReviewStatus::SENT->value) {
        throw new RuntimeException('Review is already processed');
    }

    $product = $this->productRepository->findById($review->product_id, needLock: true);

    $transaction = $this->dbConnection->beginTransaction();

    $this->saveReviewResult($review, ReviewStatus::ACCEPTED);
    $this->acceptProductChanges($product, $review);

    $transaction->commit();

    return $review;
  }

  private function saveReviewResult(Review $review, ReviewStatus $status): void
  {
    $review->status = $status->value;
    $review->processed_at = DateHelper::getCurrentDate();
    $this->reviewRepository->save($review);
  }

  private function acceptProductChanges(Product $product, Review $review): void
  {
    foreach ($review->field_values as $field => $fieldChange) {
        $newValue = $fieldChange['new'];
        $product->$field = $newValue;
    }
    $product->status = ProductStatus::PUBLISHED;
    $this->productRepository->save($product);

    $this->productChangeRepository->deleteById($product->id);
  }
}

Переносим изменения из ревью в товар и удаляем запись с изменениями. Ставим нужные статусы в сущностях.

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

Обратите внимание, тут ReviewController это отдельный контроллер для внутреннего API, которое может быть на отдельном домене и недоступно для пользователя. То есть в пространстве имен "frontend" этот метод вообще не существует. Действия с сущностью разделяются на независимые группы, а не находятся в одном большом классе.

Отмена ревью

<?php

class ReviewController
{
  public function actionDecline(int $id): Response
  {
    $review = $this->findEntity($id, needLock: true);

    $review = $this->reviewService->decline($review);

    return $this->successResponse($review->toArray());
  }
}


class ReviewService
{

  public function decline(Review $review): Review
  {
    if ($review->status !== ReviewStatus::SENT->value) {
        throw new RuntimeException('Review is already processed');
    }

    $product = $this->productRepository->findById($review->product_id, needLock: true);

    $transaction = $this->dbConnection->beginTransaction();

    $this->saveReviewResult($review, ReviewStatus::DECLINED);
    $this->declineProductChanges($product);

    $transaction->commit();

    return $review;
  }

  private function declineProductChanges(Product $product): void
  {
    $product->status = ProductStatus::HIDDEN;
    $this->productRepository->save($product);

    $this->productChangeRepository->deleteById($product->id);
  }
}

Просто удаляем запись с изменениями. Ставим нужные статусы в сущностях.

Размышления на тему

Разное понимание

Знатоки DDD и Clean Architecture могут сказать, что кроме сущностей нужны так называемые Use Cases, где и будут коммиты транзакций, вызовы других сервисов, и прочие вещи. Так и есть. Дело в том, люди, которые не знакомы с DDD и Clean Architecture, называют их просто сервисы. В этом, как мне кажется, и есть причина взаимного непонимания. Потом кто-то встречает статью Фаулера про Anemic Domain Model, где он говорит, что в сервисах логики быть не должно, и начинаются попытки ее оттуда убрать.

Я встречал много ситуаций, когда принцип "логика должна быть в сущностях" возводят в абсолют и помещают в сущность то, что должно быть в юзкейсах, из-за чего код постепенно усложняется и становится неподдерживаемым. Например, мне советовали сделать в сущности публичное поле "sentEmails", чтобы хранить там письма, созданные бизнес-действием, потом забирать их специальным кодом и отправлять фактически.

Другой пример - фильтры на странице списка сущностей. Это именно бизнес-логика, работа полей обсуждается на уровне бизнеса. Например, "Если введено значение в поле фильтра "Текст", надо проверять на наличие текста с полным совпадением следующие поля товара: название, описание, SKU, производитель". Эту логику тоже нельзя поместить в сущность, со значениями полей отдельного объекта она никак не связана.

Логику фильтров часто помещают в репозиторий. Репозиторий не должен содержать бизнес-логику, это не его ответственность. К тому же для страницы списка требуется количество страниц или общее количество записей, а от репозитория обычно ожидается просто массив объектов. Поэтому правильно помещать эту логику в сервис, который будет обрабатывать фильтры, сортировку и пагинацию в соответствии с данными из HTTP-запроса и возвращать все нужные данные для отображения страницы, а не только массив сущностей. Сервис будет использовать Query Builder, чтобы настроить запрос к хранилищу данных и передать его в репозиторий.

Даже в DDD обработка начинается с сервисных классов, которые вызываются из контроллера, они так и называются, Application Service. Только по правилам этого подхода в этих классах не должно быть бизнес-логики.

Распространенные аргументы

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

Свойства сущности это детали ее реализации, которые нужно скрывать.

Это не так. Если бы свойства сущности были деталями ее реализации, вы бы никогда про них не узнали при анализе предметной области. Вы наблюдаете сущность "Товар" со стороны и видите у нее свойства (X, Y, Z), значит они публично доступны для вас как наблюдателя со стороны.

Детали реализации это то, как мы храним эти свойства. Например, в ActiveRecord поле "name" может храниться как $this->data['name']. Поэтому $entity->data['name'] это детали реализации, а $entity->name с магическим методом __get() нет. Эти детали раскрывать не надо, а существование свойства можно и нужно, так код будет содержать правильную модель предметной области.

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

Ну как это не сможет. Создаем в сущности новый метод, там устанавливаем свойства как нам нужно. Заметить это можно будет только на код-ревью. Вот и с логикой в сервисе так же. Просто граница бизнес-логики это сервис, а не сущность. Все действия с сущностью делаются через сервис. Единственный вариант когда кто-то будет манипулировать сущностью напрямую - это если он пишет свой сервис. Это то же самое, как добавить новый бизнес-метод в сущность. В обоих случаях мы не используем существующие проверки, а пишем новые в соответствии с новыми требованиями.

Сущность должна сама проверять свои инварианты.

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

Что моделирует сервис?

Может появиться вопрос - если сервис это часть доменного слоя, то что он моделирует?

Представьте магазин без информационной системы, где все заказы оформляются на бумаге.
Вы приходите к сотруднику, говорите "я хочу купить это, это, и вот это", он оформляет документ с заказом.
Бумажный документ сам себя не заполняет, его заполняет сотрудник.
Правильность данных документ не контролирует, ее контролирует сотрудник.
Но он не устанавливает правила заполнения, он лишь им следует. Как процессор, выполняющий программу.
Он узнал их от кого-то другого, как и все другие сотрудники.
Правила создания заказа устанавливаются владельцем бизнеса.
Бизнес пишет инструкцию как оформлять заказ, и все сотрудники ее изучают.
Так вот, сервис с бизнес-логикой это модель инструкции.

Цель статьи

Мы рассмотрели несколько небольших бизнес-действий. Цель этой статьи в том, чтобы показать, что сервисы всегда нужны, и нельзя перенести всё, что в них находится, в сущности без усложнения кода и нарушения уровней абстракции. Основные сложности это проброс зависимостей, обеспечение последовательности действий с хранилищами данных, и логика, связанная со списками сущностей.

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

Большинство бизнес-сущностей пассивны по своей природе (документ сам себя не заполняет), поэтому анемичная модель это следствие бизнес-требований. Любая бизнес-инструкция с технической точки зрения является процедурой, которая манипулирует некоторым набором сущностей.

Логика в сервисах является прямой формализованной записью бизнес-требований, которые описывают взаимодействие сущностей. Сущностями можно манипулировать только через сервис, который всегда моделирует какое-то бизнес-действие. Если кто-то привел сущность в неконсистентное состояние, значит он неправильно реализовал свое бизнес-действие.

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

Желающие могут попробовать реализовать аналогичное поведение в соответствии со своим видением подхода с логикой в сущностях.

Репозиторий

Вывод:
Всегда начинайте логику с сервисов, не нужно пытаться обойтись без них.

Теги:
Хабы:
Всего голосов 8: ↑6 и ↓2+7
Комментарии133

Публикации

Истории

Работа

Ближайшие события