-
-
Notifications
You must be signed in to change notification settings - Fork 6
Domain
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.
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.
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 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.
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).
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'),
];
}
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,
]);
}
}
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:
- 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.
- 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.
- 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.
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 = [];
}
Slim app basics
- Composer
- Web Server config and Bootstrapping
- Dependency Injection
- Configuration
- Routing
- Middleware
- Architecture
- Single Responsibility Principle
- Action
- Domain
- Repository and Query Builder
Features
- Logging
- Validation
- Session and Flash
- Authentication
- Authorization
- Translations
- Mailing
- Console commands
- Database migrations
- Error handling
- Security
- API endpoint
- GitHub Actions
- Scrutinizer
- Coding standards fixer
- PHPStan static code analysis
Testing
Frontend
Other