Domain

Table of contents

Loading...

Introduction

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

In slim-example-project, the Domain mainly contains services and DTOs.

Service classes

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 to the attributes. Getter and setter methods for each value would unnecessarily bloat the class.

The constructor accepts an array with the values to populate the object. This is for convenience when receiving data from the database or other sources.
It does put the responsibility of filling the object to DTO itself and is a design choice I've made for now.

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. 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 client "item" object:

namespace App\Domain\Client\Data;

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

    /**
     * Serialize DTO to array for database
     * @return array keys must be identical with the database table column names
     */
    public function toArrayForDatabase(): array
    {
        return [
            'first_name' => $this->firstName,
            'last_name' => $this->lastName,
            // If birthdate not null, return given format
            'birthdate' => $this->birthdate?->format('Y-m-d'),
        ];
    }

    /**
     * Define how json_encode() should serialize the object
     * @return array in the format expected by the frontend
     */
    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.

This 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.

The following is an example of a result data object for the client read use case.

File: src/Domain/Client/Data/ClientReadResult.php

namespace App\Domain\Client\Data;

use App\Domain\Note\Data\NoteData;

/** Aggregate DTO to store data for client read page */
class ClientReadResult extends ClientData
{
    // Amount of notes for the client to know how many content placeholders to display
    public ?int $notesAmount = null;

    // Main note data
    public ?NoteData $mainNoteData = null;

    // Privileges for the authenticated user on the client
    // If allowed to change personal client values i.e. first name, last name, birthdate
    public ?string $generalPrivilege = null;
    // If allowed to change the client status
    public ?string $clientStatusPrivilege = null;
    // If allowed to change the assigned user
    public ?string $assignedUserPrivilege = null;
    // If allowed to create a new note
    public ?string $noteCreationPrivilege = null;    

    public function __construct(?array $clientData = [], ?array $privileges = [])
    {
        parent::__construct($clientData);

        // Populate mainNote if set
        $this->mainNoteData = new NoteData([
            'id' => $clientResultData['main_note_id'] ?? null,
            'message' => $clientResultData['note_message'] ?? null,
            'hidden' => $clientResultData['note_hidden'] ?? null,
            'user_id' => $clientResultData['note_user_id'] ?? null,
            'updated_at' => $clientResultData['note_updated_at'] ?? null,
        ]);
    }
    /**
     * Define how json_encode() should serialize the object
     * @return array in the format expected by the frontend
     */
    public function jsonSerialize(): array
    {
        return array_merge(parent::jsonSerialize(), [
            'notesAmount' => $this->notesAmount,
            'mainNoteData' => $this->mainNoteData,

            'personalInfoPrivilege' => $this->generalPrivilege,
            'clientStatusPrivilege' => $this->clientStatusPrivilege,
            'assignedUserPrivilege' => $this->assignedUserPrivilege,
            'noteCreationPrivilege' => $this->noteCreationPrivilege,
        ]);
    }
}

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 grow into needing 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.
  4. 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.

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

I was guilty of using the same Result DTO for more than one purpose for "convenience" because client read result and client list result shared some similarities. 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 third type of DTO containing a collection of items or "result" objects is useful.

These DTOs should be called after the main "Result" DTOs 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 = [];

    // Statuses and a list of users that can be assigned are needed for dropdown options
    public ?array $statuses;
    public ?array $users;
}

Access user id and request ip-address in Domain

The Domain layer must not access the session or request directly. This is concern of the Application layer.

For the Domain to be able to access the user id and request ip-address, it has to be retrieved first with a middleware and then injected into the Domain service via a Data Transfer Object.

UserNetworkSessionDataMiddleware

This middleware retrieves the user id and request ip-address from the session and request and stores it in a DTO.

File: src/Application/Middleware/UserNetworkSessionDataMiddleware.php

<?php

namespace App\Application\Middleware;

use App\Application\Data\UserNetworkSessionData;
use Odan\Session\SessionInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

/**
 * Middleware that adds client network data such as IP address and user agent
 * as well as user identity id to the clientNetworkData DTO.
 */
final readonly class UserNetworkSessionDataMiddleware implements MiddlewareInterface
{
    public function __construct(
        private UserNetworkSessionData $clientNetworkData,
        private SessionInterface $session,
    ) {
    }

    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        // Server params will be null in testing
        $ipAddress = $request->getServerParams()['REMOTE_ADDR'] ?? null;
        $userAgent = $request->getServerParams()['HTTP_USER_AGENT'] ?? null;

        // Add ip address to the ipAddressData DTO object
        $this->clientNetworkData->ipAddress = $ipAddress;
        $this->clientNetworkData->userAgent = $userAgent;

        // Only initialize userId if it exists in session
        if ($userIdFromSession = $this->session->get('user_id')) {
            $this->clientNetworkData->userId = $userIdFromSession;
        }

        return $handler->handle($request);
    }
}

File: src/Application/Data/UserNetworkSessionData.php

<?php

namespace App\Application\Data;

class UserNetworkSessionData
{
    public ?string $ipAddress = null;
    public ?string $userAgent = null;
    public int $userId;
}

This middleware has to be added to the middleware stack before the session start middleware but after any middleware that might use the UserNetworkSessionData DTO.

File. config/middleware.php

<?php

use Slim\App;

return function (App $app) {
    $app->addBodyParsingMiddleware();

    // Slim middlewares are LIFO (last in, first out) so when responding, the order is backwards
    // https://samuel-gfeller.ch/docs/Slim-Middlewares#order-of-execution

    // ... middlewares that need the session or UserNetworkSessionData

    // Retrieve and store ip address, user agent and user id (has to be BEFORE SessionStartMiddleware as 
    // it is using it but after any middleware that needs UserNetworkSessionData)
    $app->add(\App\Application\Middleware\UserNetworkSessionDataMiddleware::class);

    // Has to be after every middleware that needs a started session (LIFO)
    $app->add(\Odan\Session\Middleware\SessionStartMiddleware::class);

    $app->addRoutingMiddleware();

    // ... other middlewares
};

Injecting the UserNetworkSessionData into the Domain service

The UserNetworkSessionData DTO can now be injected into the Domain service via the constructor.

File: src/Domain/Module/Service/ItemCreator.php

<?php

namespace App\Domain\Module\Service;

use App\Application\Data\UserNetworkSessionData;

final readonly class ItemCreator
{
    public function __construct(
        private UserNetworkSessionData $userNetworkSessionData,
    ) {
    }

    public function createItem(array $itemData): int
    {
        // Use the user id from the UserNetworkSessionData DTO
        $userId = $this->userNetworkSessionData->userId;

        // Create the item
        // ...
    }
}
^