Skip to content
Samuel Gfeller edited this page Jan 25, 2024 · 17 revisions

As mentioned here, the Domain is the center layer that contains the core business logic of the application.
It makes all the calculation and coordinates with the infrastructure's database repositories or other adapters such as the mailer before returning to the Application layer.

In this project, the Domain mainly contains DTOs and services.

Service

The application logic happens here. Services are "do-er" classes that have one specific job to do.

Each service should be created in adherence to the Single Responsibility Principle.
This means that each service is responsible for only one purpose which often means having only one public method.
More than three public methods are a warning sign that SRP is not being followed properly.

For example, the sole task of the ClientCreator service is calling the relevant other necessary services and infrastructure adapters (e.g. repository) to create a new client and a little bit of related logic. This includes calling the ClientValidator (whose only responsibility is to validate client values), checking if the user that submitted the request has permission to create a new client (Authorization), and calling the repository to save the client values to the database.

Instead of having big service classes that do everything, methods should be split into own services that work together via dependency injection. This promotes reusability, is easier to understand and makes the code more maintainable and testable.

Naming service classes

Services should be named after the action they perform. Suitable for this are so-called "agent nouns". They denote something that performs or is responsible for an action and are formed by adding the suffix "-er" or "or" to a verb.

The first part of the service name should be the name of the "thing" that it is working with, and the second part is a verb that describes what the service does, transformed into an agent noun.

Examples: "PasswordChanger", "VerificationTokenCreator", "UserDeleter", "ClientFinder" etc.

Data Transfer Object (DTO)

Data Transfer Objects are objects that hold only data and optional serialization and deserialization mechanisms.
They are simple objects that don't contain any business logic. As the name suggests, they can be used to transfer data between different layers.

Attributes are public to facilitate the access towards them. Getters and Setters for each value would only bloat the class.

The constructor accepts an array with all values to populate the object for convenience when receiving data from the database or other sources.
This does put the responsibility of filling the object to DTO itself and is a design choice I've made for now, but people have different opinions on this. You have to know for yourself where you want to populate the DTO.

In this project, different types of DTOs are being used. Examples follow.

Serialization

Data may need to be serialized when used by other layers.

If the Action wants to return data to the client via JSON, it needs to be serialized to JSON.
This can be done by implementing the \JsonSerializable interface and adding a jsonSerialize() method. The return value of this method will be used when doing json_encode() on the object.

If the Repository wants to insert the Data into the database, it needs to be serialized to an array. In this project, this is done in a toArrayForDatabase method ("ForDatabase" suffix to make it clear that the array keys must be identical with the database table column names).

DTO containing an item data

DTOs that are designed to contain values of a specific item or resource such as a database table should be named after it with the suffix "Data".

For example, the ClientData DTO contains the values of the client table.

This is an example of the "item" client data-object:

namespace App\Domain\Client\Data;

use App\Domain\Client\Enum\ClientVigilanceLevel;

/** Simplified Client Item DTO*/
class ClientData implements \JsonSerializable
{
    public ?string $firstName = null;
    public ?string $lastName = null;
    public ?\DateTimeImmutable $birthdate = null;

    public function __construct(?array $clientData = [])
    {
        $this->firstName = $clientData['first_name'] ?? null;
        $this->lastName = $clientData['last_name'] ?? null;
        $this->birthdate = isset($clientData['birthdate']) ? new \DateTimeImmutable($clientData['birthdate']) : null;
    }

    public function toArrayForDatabase(): array
    {
        $clientArray = [
            // id set below
            'first_name' => $this->firstName,
            'last_name' => $this->lastName,
            // If birthdate not null, return given format
            'birthdate' => $this->birthdate?->format('Y-m-d'),
        ];
    }

    public function jsonSerialize(): array
    {
        return [
            'id' => $this->id,
            'firstName' => $this->firstName,
            'lastName' => $this->lastName,
            'birthdate' => $this->birthdate?->format('Y-m-d'),
        ];
    }

"Result" DTO containing aggregate data

DTOs may also contain other data that is not directly related to the item but needed in a specific use case. For instance, when displaying the client read page, privileges are relevant for the renderer to display the correct buttons.

The second type of DTOs is suffixed with "Result" to make it clear that the DTO acts as a specific "Result" object.

If it contains the same values as the base item object, it can extend the item DTO so that the attributes don't have to be defined twice.

Example of a result data object for the use case client read:

namespace App\Domain\Client\Data;

use App\Domain\Authorization\Privilege;

/** Aggregate DTO to store ClientData combined privileges */
class ClientReadResult extends ClientData
{
    // If allowed to change the main values i.e. first name, last name, birthdate
    public ?Privilege $mainDataPrivilege = null;
    // If allowed to change the client status
    public ?Privilege $clientStatusPrivilege = null;
    // If allowed to change the assigned user
    public ?Privilege $assignedUserPrivilege = null;
    
    public function __construct(?array $clientData = [], ?array $privileges = [])
    {
        parent::__construct($clientData);
        $this->mainDataPrivilege = $privileges['main_data'] ?? null;
        $this->clientStatusPrivilege = $privileges['client_status'] ?? null;
        $this->assignedUserPrivilege = $privileges['assigned_user'] ?? null;
    }
    
        public function jsonSerialize(): array
    {
        return array_merge(parent::jsonSerialize(), [
            'mainDataPrivilege' => $this->mainDataPrivilege?->value,
            'clientStatusPrivilege' => $this->clientStatusPrivilege?->value,
            'assignedUserPrivilege' => $this->assignedUserPrivilege?->value,
        ]);
    }
}

One Result DTO per use case

Such result data objects should really only be used for a specific use case such as client read page. If it starts to serve multiple use cases even with slightly different requirements, it quickly becomes bigger and bigger with more attributes. This makes it hard to maintain because:

  1. For every little change, all the use cases where the result DTO is being used will be affected which can lead to unexpected side effects if not every use case is meticulously tested.
  2. There would be multiple reasons (as many as there are use cases using it) for it to change so more things likely to go wrong.
  3. Every use case would probably need its own attributes or workarounds which quickly clutters the Result object and makes it more complex and less flexible for bug-free changes. It's a vicious circle.

This is why the Single Responsibility Principle, and high cohesion is so important.

So in the best case, it's more work to maintain properly because a lot more can go wrong. In the worst case, it's a ticking time bomb.

I was guilty of using the same Result DTO for more than one purpose for "convenience". More on this here.

DTO containing a collection of items

Result DTOs are useful when a single resource is needed.
But when the client expects a list of items, a DTO containing a collection of items or "result" objects is needed.

These DTOs should be called after the Result DTO they are carrying suffixed with the word "Collection".

Example of a DTO with a collection of ClientListResult objects:

namespace App\Domain\Client\Data;

class ClientListResultCollection
{
    // Collection of ClientListResult objects
    /** @var ClientListResult[]|null */
    public ?array $clients = [];
}
Clone this wiki locally