Anemic domain model and logic in services

An anemic domain model is a model where entities contain only properties, and business logic is located in services. Its opposite is a rich domain model, where logic is in entities, and services are recommended to be written only in rare cases.

In this article I want to show why service logic is a better approach. We will look at an example of business requirements and their implementation with the Anemic domain model.

Business logic requires dependencies, and it is difficult to forward them to an entity that is loaded from the database at an arbitrary moment during execution. Also, with the Rich domain model, all business actions that change it are placed in the entity. This leads to the fact that the entity turns into a God-object, and the code becomes more difficult to maintain.

For example, there is an entity “Order” with a field “status”. An order can have several dozen statuses, and each status has its own script that sets it. This means that in essence there will be several dozen methods. And that's just one field. In addition to its own fields, a product usually has images, and the logic for changing them in this approach should also be placed in the “Product” entity, since it is the aggregate root.

Business requirements

Let's take the following business requirements. The example is close to the real one, but in one form or another it is found in many online stores, so all matches are random, although in some cases they are quite probable.

There is an online store. There is a separate service for suppliers of goods who sell their goods through an online store.
Suppliers are not employees of the online store.
The supplier adds products, edits the data, and then sends them for review to online store managers who work in another system.
Managers check the name, description and other parameters of the product, and if everything is fine, they publish the product in the online store.
If any data does not meet the requirements, the request is rejected and the product is returned to the supplier for editing.
While the product is under inspection, the supplier cannot edit it.

While the product is being edited by the supplier, any fields other than the name are optional.
When submitting for review, the category, title, and description of at least 300 characters must be filled in.
It is advisable to return all validation errors together, rather than one at a time.
Sending a product for inspection to another system is carried out by calling the API.
You need to send the old and new field values ​​in the data to show a nice diff in the interface.
We need to store the history of submissions for verification in our database.
You should send it only after the data has been successfully saved to our database.
Another system sometimes works unstable, so if the API call fails, this should be displayed in the request status. For example, after successful sending, set the status to “Sent”. Then the request is re-sent manually using a button in the admin panel.
Queues? Yes, there are queues in the plan, the team that deals with them will work on our project in 2 months.

The business has a requirement – not to change the values ​​of the fields in the product until the manager accepts the changes.
When editing a product by the supplier, changes must be saved in a separate table, when accepted, transferred to the product, and deleted when rejected.
When opening a product page, you need to apply changes and show new values. However, the business agrees that filters on the listing page will not take them into account.

There is also functionality for mass loading of data via CSV and mass sending for verification in the form of console background tasks. As part of the example, we will not implement it, but we must take into account that changing the data of one product can occur simultaneously in 2 different processes.

A review request is a separate entity; in the code it is designated by the name “Review”. It is assumed that the user has access to them and can override them at will. The name “review” in Russian can be considered a short version of the name “request for review”.

You can see the implementation in the repository.

Implementation

Entities

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

Product creation

<?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;
  }
}

Everything is standard here, an input DTO with validation rules and a service that processes it. When creating, only the “name” field is filled in. It will be more interesting later.

Creating a separate DTO for input data with validation rules for each business action is a good approach, since usually any input data needs to be validated, and often it contains more than one field. It is also convenient to add new fields without changing method signatures.

Preservation of goods

There are 2 points to consider:
– while the product is under inspection, it is prohibited to edit it;
– a task of bulk data loading or bulk sending for verification that the user launched may be running in the background, and right now it is about to process this product.

<?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;
  }
}

The business logic must receive a ready-made entity, because when there is no entity with the specified id, we need to return another HTTP response code, and this is the responsibility of the controller. Therefore, the entity must be loaded in the controller. You can pass an id to an entity and simulate this with exceptions – throw a logical one from the service (EntityNotFound), catch it in the controller, and throw a technical one (NotFoundHttpException), catch it in the controller calling code and return a response. But this is actually an imitation of the controller inside the service, as well as an implicit substitution of the value returned from the method. Exceptions should be used for exceptional situations for which handling is not provided, for example, lack of connection to the database, and handling of a non-existent id can be provided in advance.

Loki

Locks are used to prevent simultaneous processing.

<?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;
  }
}

Method lock() concatenates the class name and id and calls the MySQL function GET_LOCK(:str, :timeout) (documentation).

It's a mutex, that's how it works. The first process requests a mutex with a specific name, the mutex is marked busy. The second one, when requesting a mutex with the same name, will wait until it is freed, but no longer than specified in “timeout”.
The mutex requested by this function is automatically released when the connection to the database is closed.

There are no examples of explicit release in the code, since in PHP the connection to the database is closed after processing the request. If you release it explicitly, then this must be done in the controller after calling the service. Earlier it is impossible, since the business logic has not yet completed; later there is no point, it will only delay other processes that are going to work with this object.

Sometimes you can use a SQL statement FOR UPDATEbut it only works within a transaction, and processing can be long and use network calls, or require 2 separate transactions.

It is better to block only aggregate roots, otherwise it will be difficult to work with. That is, for example Order, not OrderItem.

The point is not only to block from simultaneous changes, but also to block from changes during checks. We check the length of the description before sending a product for review, when it is sufficient, and in a parallel process it is changed to insufficient, as a result, a product with an insufficient description will be sent to review. Therefore, there must be locks in both saving and sending for review.

Ideally, a lock on an object should be obtained for any actions that change this object. The lock must be maintained throughout the execution of business logic, including pre-validation. There is no point in checking the status or other fields before receiving the lock, because while we are checking, the field may change in another process. First we get the lock, then we load the entity, then we do checks, then we call the change logic.

Therefore, you need to be careful with framework components that automatically load an entity by id in the controller, such as EntityValueResolver in Symfony. If you have any data checks on this entity that affect the execution of actions that you do not want to repeat, then such loading is useless. You need to get the lock and then reload the entity from the database to get the current status and be sure that it will not change during the checks before performing the action.

The reason why this is not typically done in web applications is that there is often only one source of changes to a given resource. The user created an order, he does not change it anymore. One system processed the order, changed the status, another system starts, sets its status, then a third. They do not work together with one order. Resources that a user can edit, such as articles or profile settings, are usually not edited at the same time. That's why usually everything works without locks. And when it doesn’t work, a bug appears, everyone says “well, this happens,” someone corrects the data, and that’s the end of it until next time.

Loki story

I worked on a similar project. We had the functionality of mass sending for review to another system. The managers of this system contacted us with a message that for many products the list of changes was duplicated, and the duplicates came from our system. After checking the data and timestamps in both databases, the following was revealed.

The supplier sent several tens of thousands of products for review. The items were sent one at a time, so processing took 12 hours. To launch console tasks, we used an internal queue. There, a timeout for completing one task was configured for 6 hours and retry 1 time. Therefore, after 6 hours the task started again.

Since the list of products was the same, the SQL queries in it were absolutely the same. The first task warmed up different internal database mechanisms in both systems, so they ran a little faster the second time than the first. After a couple of hours, the second task caught up with the first, and the result was a classic race condition.

The first checks that the goods have not been sent, which means they can be sent; the second checks that the goods have not been sent, which means they can be sent.
The first one sends a list of changes; the second sends a list of changes.
The first marks the item as shipped; the second marks the item as shipped.

Adding locks for all places where the product changes solved this problem and other similar ones. We have changed the queue settings, but the user can start sending 2 times himself.

Validation

Validation of the Product entity when saving is done using the isEditAllowed method.

<?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;
  }
}

We need to return a description of the error in the form of text to show it to the user in the interface; a true/false result is not suitable here. ProductValidationResult will be described below.

Saving logic

<?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;
  }
}

When saving, we check if the new value is different from the current one. We save all the different fields in a separate table in the form of JSON. There is no point in blocking ProductChange, since it changes only in actions with Product.

I decided to use repositories for all entities and entities without relationships, as the most atomic option, because this can be organized in different ways. In a real application, there will be connections in entities, and it is advisable to create repositories only for aggregate roots.

In this example, images are not considered, but we must take into account that the real product has them with a one-to-many relationship. You can make a separate controller and service for them; it is not necessary to place all the logic in ProductService.

View

<?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;
        }
    }
  }
}

This is read only, so no lock is needed. We apply field changes and return new values ​​in accordance with business requirements.

Someone might be thinking about putting an applyChanges() method on the entity. I would like to note that in a real application in Product there will be about 30 fields, images, files with instructions, this processing will take several hundred lines, so this is unlikely to be a suitable solution. You can make a separate component, or a repository, which, using findById(), will return an object with the changes applied.

Sending for review

<?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;
  }
}

We start by blocking the product from changes, then we do business checks. If the user accidentally clicks the button 2 times, the second request will wait until the first one completes and will not be resent.

We don't need to check for changes in ProductChange, but rather make a temporary copy of the product with the changes applied and check it. Because there may not be a record with changes at all, and in the Product the description will not be filled in.

Entity Validation

<?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 is needed to transmit entity validation errors. There is no DTO with input data that can be validated; the entity is loaded from the database. It stores the validation result and all loaded data so that it does not have to be loaded in the logic again. It also shows the other programmer that in order to call sendForReview(), you must first do the validation and get a ProductValidationResult. If sendForReview() took Product, this wouldn't be so obvious.

Generally speaking, even with an input DTO, it is better to first convert all the ids in the entity, and then do the validation. Even int cannot be validated without conversion. If HTML forms are used, then only strings are received from them, so to check that the numeric value of a field is greater than zero, you need to convert string to int.

For example, in the product saving function we have a category_id field. Let's imagine that a category has a status, some categories may be inactive and cannot be listed in the product. The business wants all validation errors to be returned together. Therefore, if the user specified an inactive category and an empty name, both errors must be returned at once and with the field names so that the frontend can show the user what needs to be corrected. This means that the category by id should be loaded during validation of the input DTO. And if you have loaded it, then you need to save a link to the object somewhere, so that if validation is successful, you don’t have to load it again later in the logic.

Sending logic

We have reached the most difficult method.

<?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);
  }

We need to fulfill the following requirements:
– Save the product and review to our database
– After successful saving, send the review to another system
– After successful submission, mark the review in our database as successfully submitted

We want to beautifully show in the interface what has changed to what, so we need to write down the old and new values. This is also useful for history so that you can open any request in the future.

Please note that sending a request to the API is a non-transactional interaction, so we save data in 2 steps – before sending, we save the data with one status in one database transaction, send the data, after sending we save it with a different status in another database transaction.

If sending to another system fails, the Review object will remain in the CREATED status, this can be tracked and the error can be corrected manually. For example, show the “Resend” button in the admin panel.

It is this “Save – Send – Save” logic that, in my opinion, is difficult to implement in the Rich domain model. We have several lines in a row calling setters. You can put them in the entity, but the rest of the code cannot be placed there, it must be somewhere outside the entity. It is not the responsibility of the Product or Review entity to commit database transactions. To do this, you will have to throw technical components into the entity for working with the database, which does not correspond to the purpose of the domain layer.

Business logic

Now comes the important point. This is not business logic.

<?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;
  }
}

This is business logic.

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;
  }
}

The code is a direct reflection of business requirements written in natural language. That is, the code contains model business requirements.

Business logic – implementation of rules and restrictions for automated operations.
business logic is the implementation of a subject area in an information system.

Business logic is the implementation of business requirements.

– Save product And review to our database
– After successful saving, send review to another system
– After successful sending, mark review successfully sent in our database

Please note that in business requirements the names “product” and “review” are used. If we consider business requirements as a description of an action algorithm, then these names are the designation variables. Therefore, a good software model of business requirements should contain “product” and “review” variables, and not “this” at all. The business does not discuss how you will set the values ​​of entity properties.

DDD says that you can make services with logic when several entities are used, for example, transferring money from account to account. One entity is a special case of N entities, so services are also great for it. No logic belongs to the entity, logic sets the rules for how entities should change, no matter how many of them are involved in a particular business action.

Review acceptance

<?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);
  }
}

We transfer the changes from the review to the product and delete the entry with the changes. We set the necessary statuses in the entities. In a real application there will usually be a list of changes, which are accepted and which are rejected, so it is more correct to take the values ​​​​from Review. But within the framework of the example, it would be possible to transfer changes from ProductChange.

The blocking is carried out both on Review, so that the user does not cancel it at the same time, and on Product. In this example, there is no need to block a product, since accepting or canceling a review is the only process that, according to business requirements, can change it at this moment, but in another situation it may be.

Please note that here ReviewController is a separate controller for the internal API, which may be on a separate domain and not accessible to the user. That is, in the “frontend” namespace this method does not exist at all. Entity actions are separated into independent groups rather than being in one large class.

Cancel review

<?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);
  }
}

We simply delete the entry with the changes. We set the necessary statuses in the entities.

Reflections on the topic

Different understanding

Experts in DDD and Clean Architecture can say that in addition to entities, so-called Use Cases are needed, where there will be transaction commits, calls to other services, and other things. This is true. The thing is, people who are not familiar with DDD and Clean Architecture simply call them services. This, it seems to me, is the reason for mutual misunderstanding. Then someone comes across Fowler's article about Anemic Domain Modelwhere he says that there should be no logic in the services, and attempts begin to remove it from there.

I have come across many situations where the principle “logic should be in entities” is taken to an absolute level and what should be in the use cases is placed in the entity, which is why the code gradually becomes more complicated and becomes unsupportable. For example, I was advised to make essentially a public field “sentEmails” in order to store letters created by a business action there, then retrieve them with a special code and actually send them.

Another example is filters on an entity list page. This is exactly business logic; the operation of the fields is discussed at the business level. For example, “If a value is entered in the “Text” filter field, the following product fields must be checked for the presence of text with a complete match: name, description, SKU, manufacturer.” This logic also cannot be placed in an entity; it is in no way connected with the values ​​of the fields of a separate object.

Filter logic is often placed in a repository. The repository should not contain business logic, this is not its responsibility. Additionally, a list page requires a number of pages or a total number of records, whereas a repository is typically expected to simply have an array of objects. Therefore, it is correct to place this logic in a service that will process filters, sorting and pagination according to the data from the HTTP request and return all the necessary data to display the page, not just an array of entities. The service will use the Query Builder to set up a query against the data store and submit it to the repository.

I came across a situation where the development rules stated that filters should be made in repositories, and the repository should contain no more than one method with a filter. Such repositories accepted a large DTO as input with all possible filters and sorting options from the admin panel and from the user side.

Common Arguments

I would like to comment on some common arguments that are given in favor of logic in entities.

Entity properties are details of its implementation that need to be hidden.

This is wrong. If the properties of an entity were details of its implementation, you would never know about them when analyzing the problem domain. You observe the entity “Product” from the outside and see its properties (X, Y, Z), which means they are publicly available to you as an observer from the outside.

Implementation details are how we store these properties. For example, in ActiveRecord the “name” field could be stored as $this->data['name']. That's why $entity->data['name'] these are implementation details, and $entity->name with a magic method __get() No. These details do not need to be disclosed, but the existence of the property is possible and necessary, so the code will contain the correct domain model.

No one can put an entity into an invalid state without calling my checks.

Well, how can it not? We essentially create a new method, setting the properties there as we need. This can only be noticed during a code review. It’s the same with the logic in the service. It's just that the business logic boundary is a service, not an entity. All actions with the entity are done through the service. The only option when someone will manipulate the entity directly is if he writes his own service. This is the same as adding a new business method to an entity. In both cases, we do not use existing checks, but write new ones in accordance with the new requirements.

The entity must check its own invariants.

There are often no rules that must be followed in all scenarios. In one scenario the field is optional, in the other it is required. Previously, it was optional, then it became mandatory, and there are already many entries in the database with blank entries, and information from the user is needed to fill them out. Previously, there was a constant in the check, then they asked to take the value from the configuration in the database. Validation rules are associated with a business action, not an entity.

What does the service model?

The question may arise – if a service is part of a domain layer, then what does it model?

Imagine a store without an information system, where all orders are placed on paper.
You come to an employee, say “I want to buy this, this, and this,” he draws up a document with the order.
The paper document does not fill itself out, it is filled out by the employee.
The document does not control the correctness of the data; it is controlled by the employee.
But he does not set the rules for filling, he only follows them. Like a processor executing a program.
He learned them from someone else, as did all the other employees.
The rules for creating an order are set by the business owner.
The business writes instructions on how to place an order, and all employees study it.
So, a service with business logic is an instruction model.

Purpose of the article

We looked at several small business activities. The point of this article is to show that services are always needed, and you can't migrate everything that's in them without essentially complicating the code and breaking abstraction layers. The main difficulties are forwarding dependencies, ensuring consistency of actions with data stores, and logic associated with lists of entities.

Advantages of this approach:
– The implementation is as close as possible to business requirements, which simplifies support.
– Dependencies are passed only to the service constructor; only business types are used in the method signature.
– The logic is divided into groups, there are no classes that contain all possible business actions.
– No exceptions are used to return validation results or execute logic.
– Logic, validation, serialization and data transfer protocol are separated from each other.
– The approach is compatible with any technology. This article uses Yii, but you can write it in the same style using Symfony components.
– All business actions are implemented in a similar way, regardless of the number of entities used.

Those who wish can try to implement similar behavior in accordance with their vision of the approach with logic in entities.

Repository.

Conclusion:
Always start your logic with services; you shouldn’t try to do without them.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *