DEV Community

Cover image for Clean controllers in Symfony (II): response handling
Rubén Rubio
Rubén Rubio

Posted on

Clean controllers in Symfony (II): response handling

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:

      {
          "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

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,
        );
    }
}

Enter fullscreen mode Exit fullscreen mode

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

  • Url: GET /books
  • Valid response

    • HTTP code: 200
    • Body:

      [
          {
              "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

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,
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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

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

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, 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

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(),
        ];
    }
}

Enter fullscreen mode Exit fullscreen mode

Symfony Serializer

We will use Symfony Serializer to handle the presentation in the kernel.view event. This serializer uses 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 follows2:

<?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);
    }
}


Enter fullscreen mode Exit fullscreen mode

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 Presenters 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

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);
    }
}

Enter fullscreen mode Exit fullscreen mode

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

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
            )
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

And the controller that returns a list of books is:

<?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()
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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 or this comment from Stack Overflow

  2. In Symfony 5.4, the NormalizerInterface is a bit different. You can instead use the ContextAwareNormalizer interface. 

Top comments (0)