Creating focused domain applications. A Symfony approach (Part 1)

Nacho Colomina Torregrosa - Nov 2 - - Dev Community

Introduction

This is the first post of a series that i've decided to create in order to explain how I organize my symfony applications and how I try to write code as domain-oriented as possible.
Bellow, you can find the flow diagram that I will use during all the series parts. In each post, I will focus in a concrete part of the diagram and I will try to analyze the processes involved and detect which parts would belong to our domain and how to decouple from the other parts using external layers.

Api Call Flow

Breaking down the data processing

In this first part, we will focus on the data extraction and validation processes. For the data extraction process, we will assume that the request data comes formatted as JSON.

Extracting the data

Based on the fact that we know that the request data comes within the JSON request payload, extracting the request payload (in this case extracting means obtaining an array from the JSON payload) would be as easy as using the php json_decode function.

class ApiController extends AbstractController
{
    #[Route('/api/entity', name: 'api_v1_post_entity', methods: ['POST'])]
    public function saveAction(Request $request,): JsonResponse
    {
        $requestData = json_decode($request->getContent(), true);
        // validate data
    }
}
Enter fullscreen mode Exit fullscreen mode

Validating the data

To validate the data, we need three elements:

  • One for defining the validation rules.
  • One for transforming the requested data into a validable object.
  • One for validating the extracted data based on those rules.

Defining the validation rules

For the first one, we are going to create a DTO (Data Transfer Object) which will represent the incoming data and we will use the Symfony validation constraints attributes to specify how this data must be validated.

readonly class UserInputDTO {
    public function __construct(
        #[NotBlank(message: 'Email cannot be empty')]
        #[Email(message: 'Email must be a valid email')]
        public string $email,
        #[NotBlank(message: 'First name cannot be empty')]
        public string $firstname,
        #[NotBlank(message: 'Last name cannot be empty')]
        public string $lastname,
        #[NotBlank(message: 'Date of birth name cannot be empty')]
        #[Date(message: 'Date of birth must be a valid date')]
        public string $dob
    ){}
}
Enter fullscreen mode Exit fullscreen mode

As you can see, we have defined our input data validation rules within the recently created DTO. These rules are the following:

  • email: Cannot be empty and must be a valid email
  • firstname: Cannot be empty
  • lastname: Cannot be empty
  • dob (Date of birth): Cannot be empty and must be a valid date.

Denormalizing the request data into the DTO

For the second one, we will use the Symfony normalizer component by which we will be able to map the request incoming data into our DTO.

class ApiController extends AbstractController
{
    #[Route('/api/entity', name: 'api_v1_post_entity', methods: ['POST'])]
    public function saveAction(Request $request, SerializerInterface $serializer): JsonResponse
    {
        $requestData = json_decode($request->getContent(), true);
        $userInputDTO = $serializer->denormalize($requestData, UserInputDTO::class);
    }
}
Enter fullscreen mode Exit fullscreen mode

As shown above, the denormalize method do the stuff and with a single line of code, we get our DTO filled with the incoming data.

Validating the DTO

Finally, to validate the data we will rely on the Symfony validator service which will receive an instance of our recently denormalized DTO (which will carry the incoming data) and will validate the data according to the DTO rules.

class ApiController extends AbstractController
{
    #[Route('/api/entity', name: 'api_v1_post_entity', methods: ['POST'])]
    public function saveAction(Request $request, SerializerInterface $serializer, ValidatorInterface $validator): JsonResponse
    {
        $requestData = json_decode($request->getContent(), true);
        $userInputDTO = $serializer->denormalize($requestData, UserInputDTO::class);
        $errors = $validator->validate($userInputDTO);
    }
}
Enter fullscreen mode Exit fullscreen mode

Identifying the domain

So far, we have broken down the process of Extracting and Validating Data into four parts:

  • Extract an array from the JSON request payload.
  • Create a DTO to represent the incoming data and their validation rules.
  • Denormalize the array into a DTO which contains the validation rules.
  • Validate the DTO.

The question now is: Which of these parts do belong to our domain?
To answer the question, Let's analyze the processes involved:

1.- Extracting data: This part only uses the "json_decode" function to transform the incoming data from json to array. It does not add business logic so this would not belong to the domain.

2.- The DTO: The DTO contains the properties associated with the input data and how they will be validated. This means that the DTO contains business rules (the validation rules) so it would belong to the domain.

3.- Denormalize data: This part simply uses an infrastructure service (a framework component) to denormalize the data into an object. This does not contains business rules so this would not belong to our domain.

4.- Validating data: In the same way as the Denormalize data process, the validating data process also uses an infraestructure service (a framework component) to validate the incoming data. This does not contains business rules since they are defined in the DTO so it would not be part of our domain either.

After analyzing the last points, we can conclude that only the DTO will be part of our domain. Then, what do we do with the rest of the code?

Creating an application service

Personally, I like including this kind of processes (extracting, denormalizing and validating data) into the application layer or service layer. Why ?, let's introduce the application layer.

The Application layer

In short, the application layer is responsible for orchestration and coordination, leaving the business logic to the domain layer. Moreover, it acts as an intermediary between the domain layer and external layers such as the presentation layer (UI) and the infrastructure layer.
Starting from the above definition, we could include the Extracting, Denormalizing and Validating processes into a service in the application layer since:

  • It coordinates the incoming data processes.
  • It uses the infrastructure services (PHP JSON function, Symfony normalize component and Symfony validation component) to process the incoming data.
  • It applies the domain rules by passing the DTO to the validation infrastructure service.

Perfect, we are going to create an application service to manage this processes. How are we going to do it ? How are we going to manage the responsibilities?

Analyzing the responsibilities

The Single Responsibility Principle (SRP) states that each class should be responsible for only one part of the application's behavior. If a class has multiple responsibilities, it becomes more difficult to understand, maintain, and modify.

How does this affect to us ? Let's analyze it.
So far, we know that our application service must extract, denormalize and validate the incoming data. Knowing this, it is easy to extract the following responsibilities:

  • Extract data
  • Denormalize data
  • Validate data

Should we split these responsibilities into 3 different services? I do not think so. Let me explain.

As we have seen, each responsibility is managed by an infrastructure function or component:

  • Extracting responsibility: PHP json_decode function
  • Denormalize data responsibility: Symfony normalizer component
  • Validate data responsibility: Symfony validation component

As the application service can delegate these responsibilities to the infrastructure services, we can create a more abstract responsibility (Process data) and assign it to the application service.

class DataProcessor {
    public function __construct(
       private SerializerInterface $serializer,
       private ValidatorInterface $validator
    ){}

    public function processData(string $content, string $dtoClass): object
    {
        $requestData = json_decode($content, true);
        $userInputDTO = $serializer->denormalize($requestData, UserInputDTO::class);
        $errors = $validator->validate($userInputDTO);
        if(count($errors) > 0) {
            throw new ValidationException($errors);
        }

        return $userInputDTO
    }
}
Enter fullscreen mode Exit fullscreen mode

As shown above, the DataProcessor application service uses the json_decode function and the Symfony normalizer and validator service to process the input request and return a fresh and validated DTO. So we can say that the DataProcessor service:

  • Coordinates all tasks related to the processing of input data.
  • Communicates the outside world (infrastructure services) with the domain business rules (DTO).

As you may have noticed, the DataProcessor service throws a Symfony ValidationException when the validation process finds an error. In the next post of this series, we will learn how to apply our business rules to structure the errors and finally present them to the client.

I know that we could remove the DataProcessor service and use the MapRequestPayload as the service application layer to extract, denormalize and validate the data but, given the context of this article, I thought it more convenient to write it this way.

Conclusion

In this first article, we have focused on the Extracting and Validating data processes from the flow diagram. We have listed the tasks involved within this process and we have learned how to detect which parts belong to the domain.
Knowing which parts belong to the domain, we have written an application layer service that connects the infrastructure services which the domain rules and coordinates the extracting and validating data process.
In the next article, we will explore hot to define our business rules to manage exceptions and we will also create a domain service which will be responsible of transforming the Input DTO into a persistible entity.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player