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" }
- HTTP code:
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,
);
}
}
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" }, ]
- HTTP code:
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,
);
}
}
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:
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, theview
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 aResponse
.
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(),
];
}
}
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);
}
}
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
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
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
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 thekernel.view
event to handle controller responses at a centralized point. - We simplified our controllers, so they have the minimum amount of logic.
-
To read more about good practices in API design, check this post by Swagger or this comment from Stack Overflow. ↩
-
In Symfony 5.4, the
NormalizerInterface
is a bit different. You can instead use theContextAwareNormalizer
interface. ↩
Top comments (0)