Table of Content
- Table of Content
- Preface
- Introduction
- DTO Utilization
- Builder Pattern
- Response and Request
- Conclusion
Preface
This article consists of two parts. The part one is an introduction to the problem and a presentation of an architectural solution. The second part presents a final solution as a symfony bundle based on the knowledge of the first part.
Introduction
One of the tasks of an API is handling the communication between clients and endpoints. This means serialization and deserialization of data and handling that data within the backends business logic. Now it comes to choose where to deserialize your JSON into and back. Working in a small project you'll probably directly use entities for de-/serialization. It'll be just fine to also use serialization groups for the few entities. But when you are working in a large project with a lot of entities and API endpoints using them for de-/serialization is a bad decision.
Why are entities bad for de-/serialization?
For a bigger project there are clear disadvantages of utilizing entities for this task:
- The first disadvantage of using entities as an API response is, that coupling the database schema to the API response schema is a bad architectural decision. You'll loose the flexibility. Schema changes on the one or the other side will be propagated to the another side.
- Keeping an eye on different groups will be a pain.
- I've seen projects where one entity was involved in many API endpoint responses. Each response should present a different shape of the entity. So about five groups were required to construct different responses for this entity. If you also imagine, these groups are then spread among other entities which are related to this one and used in other endpoint responses, you can understand that managing these groups and keeping an eye on them is really a big adventure.
- And the last one is of course the big mess in your entities regarding the amount of meta data to
control: ORM, Serialization and probably validation.
- If you are using OpenAPI (Swagger) you'll need even more meta data in your entities.
Example of such a multipurpose entity:
<?php
#[ORM\Index(
name: 'name_idx',
columns: ['name'],
)]
#[ORM\Entity(repositoryClass: 'App\Repository\ImageRepository')]
class Image
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
#[Ignore]
private ?int $id = null;
#[ORM\Column(type='string')]
#[Assert\NotBlank()]
#[Groups(['gallery', 'profile'])]
#[OA\Property(description='Alternate text')]
private ?string $alt = null;
#[ORM\Column(type='string')]
#[CustomValidator()]
#[Groups(['gallery', 'post'])]
#[OA\Property(nullable=true)]
private ?string $name = null;
#[ORM\Column(type='string')]
#[Assert\NotBlank()]
#[Groups(['gallery', 'profile', 'post'])]
#[OA\Property(format='url')]
private ?string $url = null;
// .. setters, getters
}
We have only three property here and thats already too much, right?
The Solution
The solution for this is utilization of DTOs - Data Transfer
Objects. These objects can take any shape you require for the given situation without being coupled with your database schema. These objects are defined per Plain Old PHP Objects. That way it's possible to clearly separate the domains: entities for database, DTOs anything else including API responses.
API Response/Request Schema
This is a bigger topic, so I'll bring only the keypoints. When the API is built, the schema of the requests and responses is designed by usecase. Sure, in some cases it'll match your database schema, but the overall premise is: the schema of an API is designed to suit the business case or use case in current situation. That is the opposite of using entities for de-/serialization and coupling with the database schema. The consumers of the API will need data in a form according to the use case and not according to the database schema.
DTO Utilization
Lets say our API creates and provides data about students, universities and their addresses. Here are the four DTOs:
<?php
class Student
{
private ?string $name = null;
private ?int $semester = null;
private ?Address $address = null;
private ?Faculty $faculty = null;
// ... setters, getters
}
class University
{
private ?string $name = null;
private ?Address $address = null;
/** @var Faculty[] */
private array $faculties = [];
// ... setters, getters
}
class Faculty
{
private ?string $name = null;
private array $departments = [];
}
class Address
{
private ?string $street = null;
private ?string $houseNumber = null;
private ?string $city = null;
// ... setters, getters
}
The process of retrieving a student with his address in one request would look like this:
The process of retrieving an university with it's address would look similar. The same createAddressDTO()
function would be used here. You'll probably put this in a separate service, because you also do some fancy calculations on it, like distance to the university where the student is studying or what ever... Lets say building the Address
DTO object is a complex piece of your application with a lot of dependencies. Also the Address
DTO object is used else where in the API endpoints. This would mean the building service should be generic enough to have the ability to
build an address object for any endpoint.
Builder Pattern
The builder pattern is one of the creational design patterns like it is described by the Gang of Four in their book Design Patterns: Elements of Reusable Object-Oriented Software.
Definition:
Separate the construction of a complex object from its representation so that the same construction process can create different representations.
The builder pattern perfectly suits to our needs to build the different objects: Stundent, University, Address and Faculty:
StudentBuilder
UniversityBuilder
AddressBuilder
FacultyBuilder
Each builder should be represented as a separate class/service and build only one DTO. With this construct we gain following advantages over creating the DTOs directly in related services with the main business logic:
- Single point of responsibility: a builder does only one thing - it builds one DTO
- Separate the building of those DTOs from the main business logic
- We can use them all over API endpoints without injecting the whole unrelated service with unrelated business logic
- Also it allows us a building complex objects by composing different builders with each other
A simplified schematic:
Example of a Builder
This is how a builder could look like:
<?php
interface BuilderInterface
{
public function build(): object;
public function setData(mixed $data): void;
}
class AddressBuilder implements BuilderInterface
{
private mixed $data = null;
public function __construct(
private App\Service\SomeFancyGeoService $fancyService
) { }
public function setData(mixed $data): void
{
$this->data = $data;
}
public function build(): object
{
// ..
return $buildedAddressObject;
}
}
A schematic workflow of a builder usage could look like this:
<?php
// somewhere in StudenService or in a controller action
// $addressBuilder is injected as a dependency
$addressEntity = $addressRepository->find($id);
$addressBuilder->setData($addressEntity);
$addressDTO = $builder->build();
What we achieved here:
- The context where the DTO is built doesn't need to know anything about the representation of the
Address
DTO - The context where the builder is used benefits from the interface agreement
Builder Composition
Since we are using the same Address
and Faculty
DTOs in both Student
and University
we can compose the StundentBuilder
service utilizing the AddressBuilder
and FacultyBuilder
:
<?php
class StudentBuilder implements BuilderInterface
{
public function __construct(
private FacultyBuilder $facultyBuilder,
private AddressBuilder $addressBuilder
) {}
/**
* @param App\Entity\Student $data The student entity
*/
public function setData(mixed $data): void { /* ... */ }
public function build(): object
{
$this->addressBuilder->setData($this->entity->getAddress());
$addressDTO = $this->addressBuilder->build();
$this->facultyBuilder->setData($this->entity->getFaculty());
$facultyDTO = $this->facultyBuilder->build();
return (new Student())
->setName($this->entity->getName());
->setSemester($this->entity->getSemester())
->setAddress($addressDTO)
->setFaculty($facultyDTO)
;
}
}
A small class UML for better understanding:
The UniversityBuilder
would use the same AddressBuilder
and FacultyBuilder
to provide the address. I think you get the idea.
More Compositions
As I mentioned before the API response schema may correspond to your database schema. That is already happing above. Student
and University
will be definitely related to Address
and Faculty
entities in the database as well. But what if we make a response which has no correlation to the database schema. For example we want to return a Faculty
and the Address
where this faculty can be studied. We don't have such a relation in the database. We only have a University
which has a ManyToMany relation to Faculty
and a OneToOne relation an Address
. So we just create a new shape of the response in a manner how the use case requires it:
<?php
class FacultyLocation
{
private ?Address $address = null;
private ?Faculty $faculty = null;
// ... settrs, getters
}
The builder will look like this:
<?php
class FacultyLocationBuilder implements BuilderInterface
{
private ?App\Entity\Faculty $facultyEntity = null;
private ?App\Entity\Address $addressEntity = null;
public function __construct(
private FacultyBuilder $facultyBuilder,
private AddressBuilder $addressBuilder
) {}
/**
* @param array{App\Entity\Faculty, App\Entity\Address} $data
*/
public function setData(mixed $data): void
{
[$this->facultyEntity, $this->addressEntity] = $data;
}
public function build(): object
{
$this->facultyBuilder->setData($this->facultyEntity);
$facultyDTO = $this->facultyBuilder->build();
$this->addressBuilder->setData($this->addressEntity);
$addressDTO = $this->addressBuilder->build();
return (new FacultyLocation())
->setFaculty($facultyDTO)
->setAddress($addressDTO)
;
}
}
This might be stacked together as a collection for a map as geo points or as result list for a search.
The DTO above would be serialized to the following JSON:
{
"address": {
"city": "Duisburg",
"street": "..."
},
"faculty": {
"name": "Applied Informatics",
"departments": [
"Human Computer Interaction",
"..."
]
}
}
If you prefer a flat JSON - you're welcome to do so:
<?php
class FacultyLocation
{
private ?string $facultyName = null;
private ?string $city = null;
// ... other properties
// ... settrs, getters
}
The JSON would look like this:
{
"city": "Duisburg",
"facultyName": "Applied Informatics",
"....": "...."
}
As you can see, you're able to form any desired shape of your response.
Response and Request
Until now we only covered how to build DTOs for responses. The same process can be applied to build entities from DTOs, when a request comes in, for example from a form submit. You only need to invert the idea. Previously we used BuilderInterface::setData()
to provide an entity as a source of data and BuilderInterface::build()
to build the DTO. When building an entity from a DTOs the order is
inversed:
-
BuilderInterface::setData()
takes a DTO - a deserialized JSON from a form, for example -
BuilderInterface::build()
builds and returns an entity, ready to be saved to database
Conclusion
What we've seen here so far is:
- How to utilize the DTOs instead of entities for API requests and responses
- How to decouple the API schema from the database schema
- How to build DTOs and entities without polluting the main business logic with the creational process
- Creating a set of builders allows us to make creating DTOs more abstract
- How to compose those builder together to form every desired shape of an API response or request schema independently from the database schema
The part one of this article is more of a concept and architectural pattern, than a real world example, especially the builder interfaces and builder implementations. The part two of the article will introduce a Symfony bundle to:
- simplify the process of creating such builders
- provide the ability to organize the builders
- how to utilize them in real world examples
As soon as the part two of this article is ready, I'll inject the links here. Till than criticism, suggestions and fixes are welcome in comments.
Best Regards,
dr0bz
Top comments (0)