Clean controllers in Symfony (II): response handling
Tags: php, symfony, backend, api
Published: 2023-10-04 07:15:00

## Introduction

Suppose we have the same endpoint as in the previous post. For simplicity, we only show the correct response:

- Valid response
    - HTTP code: `200`
    - Body:
        ```json
        {
            "id": "c59620eb-c0ab-4a0c-8354-5a20faf537e5",
            "title": "Life among the Indians",
            "author": "George Catlin"
        }
        ```
        
The resulting controller after refactoring the exception handling was as follows:

```php
<?php

declare(strict_types=1);

namespace rubenrubiob\Infrastructure\Ui\Http\Controller;

use rubenrubiob\Application\Query\Book\GetBookDTOByIdQuery;
use rubenrubiob\Domain\DTO\Book\BookDTO;
use rubenrubiob\Infrastructure\QueryBus\QueryBus;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;

final readonly class GetBookController
{
    public function __construct(private QueryBus $queryBus)
    {
    }

    public function __invoke(string $bookId): Response
    {
        /** @var BookDTO $bookDTO */
        $bookDTO = $this->queryBus->__invoke(
            new GetbookDTOByIdQuery(
                $bookId
            )
        );

        return new JsonResponse(
            [
                'id' => $bookDTO->bookId->toString(),
                'title' => $bookDTO->bookTitle->toString(),
                'author' => $bookDTO->authorName->toString(),
            ],
            Response::HTTP_OK,
        );
    }
}

```

Suppose that we now have another endpoint that returns a list of books, described as:

- Url: `GET /books`
- Valid response
    - HTTP code: `200`
    - Body:
        ```json
        [
            {
                "id": "c59620eb-c0ab-4a0c-8354-5a20faf537e5",
                "title": "Life among the Indians",
                "author": "George Catlin"
            },
            {
                "id": "4f9d75b7-5dd4-4d19-8a31-8876d54cddee",
                "title": "Crazy Horse and Custer",
                "author": "Stephen E. Ambrose"
            },
        ]
        ```

The controller that handles this endpoint is the following one:

```php
<?php

declare(strict_types=1);

namespace rubenrubiob\Infrastructure\Ui\Http\Controller;

use rubenrubiob\Application\Query\Book\FindBookDTOsQuery;
use rubenrubiob\Domain\DTO\Book\BookDTO;
use rubenrubiob\Infrastructure\QueryBus\QueryBus;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;

use function array_map;

final readonly class FindBooksController
{
    public function __construct(private QueryBus $queryBus)
    {
    }

    public function __invoke(): Response
    {
        /** @var list<BookDTO> $books */
        $books = $this->queryBus->__invoke(
            new FindBookDTOsQuery()
        );

        $formattedResponse = array_map(
            fn(DTO $bookDTO): array =>
            [
                'id' => $bookDTO->bookId->toString(),
                'title' => $bookDTO->bookTitle->toString(),
                'author' => $bookDTO->authorName->toString(),
            ],
            $books,
        );

        return new JsonResponse(
            $formattedResponse,
            Response::HTTP_OK,
        );
    }
}
```

We see that, in both cases, what we do is format a `BookDTO`, directly in the first case, and within an `array` in the second one.

Now imagine we need to add a new field to `BookDTO`, that must be returned in all responses where it is used. What problem do we face? We have to add it several times, as many times as there are controllers where a `BookDTO` is in use. What would happen if we forget to add the field in some controller? Our API would return different responses for the same element.

A good API design requires uniform responses, i.e., the elements are represented in the same way everywhere. To achieve it, we have to work with people who know the application, checking the existing data and the requirements of the domain. It is a work of effort and patience. A standard such as OpenAPI could help in this task[^1].

In this post, we will see how to delegate the handling of responses from our application to the framework, the same way we delegated the handling of exceptions in the previous post.

## `kernel.view` event

As we explained in the previous post, Symfony's kernel uses an event-driven architecture, where the developer can hook to perform some actions:

![Source: Symfony documentation](/images/dev/symfony-kernel-events.png)

We see two different flows after the controller:

- Point 5, if the controller returns a `Response` object.
- Point 6, if the controller does not return a `Response` object. In that case, the `view` event is fired.

Checking the [docs](https://symfony.com/doc/current/components/http_kernel.html#6-the-kernel-view-event), we see this event allows processing the return of a controller to convert it to a `Response`:

> _If the controller doesn't return a `Response` object, then the kernel dispatches another event - `kernel.view`. The job of a listener to this event is to use the return value of the controller (e.g. an array of data or an object) to create a `Response`._

Therefore, as we did with the exceptions, we can delegate and centralize the presentation of the data returned by the controller to handle it in this event.

## Implementation

To convert the object the controller returns, we will use a serializer. It will be in charge of calling a service that we will call `Presenter`.

### `BookDTOPresenter`

This class receives a `BookDTO` and formats it. It is the service that centralizes the presentation of our domain objects.

For each element of our domain that we will need to present, we need to create a new `Presenter`.

The implementation is:

```php
<?php

declare(strict_types=1);

namespace rubenrubiob\Infrastructure\Ui\Http\Response\Presenter;

use rubenrubiob\Domain\DTO\Book\BookDTO;

final readonly class BookDTOPresenter
{
    /** @return array{
     *     id: non-empty-string,
     *     title: non-empty-string,
     *     author: non-empty-string
     * }
     */
    public function __invoke(BookDTO $bookDTO): array
    {
        return [
            'id' => $bookDTO->bookId->toString(),
            'title' => $bookDTO->bookTitle->toString(),
            'author' => $bookDTO->authorName->toString(),
        ];
    }
}

```

### Symfony Serializer

We will use [Symfony Serializer](https://symfony.com/doc/current/components/serializer.html) to handle the presentation in the `kernel.view` event. This serializer uses [normalizers](https://symfony.com/doc/current/components/serializer.html#normalizers) to convert objects to `array`, and vice versa. It allows adding new normalizers.

We can take advantage of it to add a normalizer for our `BookDTO`. It will be as follows[^2]:

```php
<?php

declare(strict_types=1);

namespace rubenrubiob\Infrastructure\Ui\Http\Response\Serializer;

use rubenrubiob\Domain\DTO\Book\BookDTO;
use rubenrubiob\Infrastructure\Ui\Http\Response\Presenter\BookDTOPresenter;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;

use function assert;

final readonly class BookDTOJsonNormalizer implements NormalizerInterface
{
    public function __construct(
        private BookDTOPresenter $presenter
    ) {
    }

    /** @param array<array-key, mixed> $context */
    public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool
    {
        return $data instanceof BookDTO;
    }

    /**
     * @param array<array-key, mixed> $context
     *
     * @return array{
     *     'id': non-empty-string,
     *     'title': non-empty-string,
     *     'author': non-empty-string
     * }
     */
    public function normalize(mixed $object, string $format = null, array $context = []): array
    {
        assert($object instanceof BookDTO);

        return $this->presenter->__invoke($object);
    }
}


```

For each object we need to present, we need to add a new normalizer. A refactoring could be to create a factory containing all the `Presenter`s and use it in both methods of the normalizer.

If we use `autoconfigure` in our services' definition, our normalizer will already be added to the default serializer of the framework.

### `ViewResponseSubscriber`

With the `Presenter` and the serializer configured, we can implement the `Subscriber` that handles the `kernel.view` event:

```php
<?php

declare(strict_types=1);

namespace rubenrubiob\Infrastructure\Symfony\Subscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ViewEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Serializer\SerializerInterface;

final readonly class ViewResponseSubscriber implements EventSubscriberInterface
{
    private const HEADERS = ['Content-Type' => 'application/json'];
    private const FORMAT = 'json';

    public function __construct(
        private SerializerInterface $serializer
    ) {
    }

    public static function getSubscribedEvents(): array
    {
        return [KernelEvents::VIEW => '__invoke'];
    }

    public function __invoke(ViewEvent $event): void
    {
        $controllerResult = $event->getControllerResult();

        if ($controllerResult === null) {
            $event->setResponse(
                new Response(
                    null,
                    Response::HTTP_NO_CONTENT,
                    self::HEADERS,
                )
            );

            return;
        }

        $response = new Response(
            $this->serializer->serialize(
                $event->getControllerResult(),
                self::FORMAT,
            ),
            Response::HTTP_OK,
            self::HEADERS,
        );

        $event->setResponse($response);
    }
}

```

What we do is set a `Response` with the serializing value that the controller returns.

We have to note that the implementation is simple: it always sets the HTTP code `200` if the controller returns a value; otherwise, it sets a `204` (`No Content`) code. We would have to add logic if we needed to support other codes.

The response format is always JSON. We could support other types if we checked the `Content-Type` header the client sent in her request.

As in the previous step, if we use `autoconfigure` in our services' definition, our normalizer will already be added to the default serializer of the framework.

## Simplified controllers

Once we set the subscriber, we can proceed to simplify our controllers.

We refactor the controller that returns one book as follows:

```php
<?php

declare(strict_types=1);

namespace rubenrubiob\Infrastructure\Ui\Http\Controller;

use rubenrubiob\Application\Query\Book\GetBookDTOByIdQuery;
use rubenrubiob\Domain\DTO\Book\BookDTO;
use rubenrubiob\Infrastructure\QueryBus\QueryBus;

final readonly class GetBookController
{
    public function __construct(private QueryBus $queryBus)
    {
    }

    public function __invoke(string $bookId): BookDTO
    {
        return $this->queryBus->__invoke(
            new GetBookDTOByIdQuery(
                $bookId
            )
        );
    }
}
```

And the controller that returns a list of books is:

```php
<?php

declare(strict_types=1);

namespace rubenrubiob\Infrastructure\Ui\Http\Controller;

use rubenrubiob\Application\Query\Book\FindBookDTOsQuery;
use rubenrubiob\Domain\DTO\Book\BookDTO;
use rubenrubiob\Infrastructure\QueryBus\QueryBus;

final readonly class FindBooksController
{
    public function __construct(private QueryBus $queryBus)
    {
    }

    /** @return list<BookDTO> */
    public function __invoke(): array
    {
        return $this->queryBus->__invoke(
            new FindBookDTOsQuery()
        );
    }
}
```

We see that the controllers only call the query bus and return what it gives back. We delegated to the framework Both the exception handling and the formatting of data.

## Conclusions

By delegating response handling to the framework, we centralize the formatting of responses. If we need to add a new field to the response, we only have to handle it in the `Presenter` to have it added in all controllers where the object is returned.

Besides, together with the exception handling we saw in the previous post, he simplified our controllers to keep them as simple as possible: receiving a request and returning a response.

This solution does not prevent returning a `Response` straight from the controller if needed, as the `kernel.view` event is only fired when the controller does not return a `Response`.

## Summary

- We saw the problem that creates formatting objects in controllers.
- We introduced `Presenters` to format our domain objects.
- We configured Symfony's serializer with a normalizer to present our objects.
- We created a `Subscriber` for the `kernel.view` event to handle controller responses at a centralized point.
- We simplified our controllers, so they have the minimum amount of logic.

[^1]: To read more about good practices in API design, check [this post by Swagger](https://swagger.io/resources/articles/best-practices-in-api-design/) or [this comment from Stack Overflow](https://stackoverflow.blog/2020/03/02/best-practices-for-rest-api-design/).

[^2]: In Symfony 5.4, the `NormalizerInterface` is a bit different. You can instead use the `ContextAwareNormalizer` interface.