Introduction
Suppose we need to implement the following endpoint of an API:
URL: GET /books/{uuid}
-
Valid response
- HTTP code:
200
-
Body:
{ "id": "c59620eb-c0ab-4a0c-8354-5a20faf537e5", "title": "Life among the Indians", "author": "George Catlin" }
- HTTP code:
-
Invalid response: book not found:
- HTTP code:
404
-
Body:
{ "error": "BookDTO with id \"c59620eb-c0ab-4a0c-8354-5a20faf537e5\" not found" }
- HTTP code:
-
Invalid response: UUID with invalid format:
- HTTP code:
422
-
Body:
{ "error": "Provided bookId format \"c59620eb-c0ab-4a0c-8354-5a20faf537e5\" is not a valid UUID" }
- HTTP code:
In Symfony, if we use a query bus, we could have the following controller in order to implement the required endpoint:
<?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\Domain\Exception\Repository\Book\BookDTONotFound;
use rubenrubiob\Domain\Exception\ValueObject\Book\BookIdFormatIsNotValid;
use rubenrubiob\Domain\Exception\ValueObject\Book\BookIdIsEmpty;
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
{
try {
/** @var BookDTO $bookDTO */
$bookDTO = $this->queryBus->__invoke(
new GetBookDTOByIdQuery(
$bookId
)
);
} catch (BookDTONotFound $e) {
return new JsonResponse(
[
'error' => $e->getMessage(),
],
Response::HTTP_NOT_FOUND,
);
} catch (BookIdFormatIsNotValid|BookIdIsEmpty $e) {
return new JsonResponse(
[
'error' => $e->getMessage(),
],
Response::HTTP_UNPROCESSABLE_ENTITY,
);
}
return new JsonResponse(
[
'id' => $bookDTO->bookId->toString(),
'title' => $bookDTO->bookTitle->toString(),
'author' => $bookDTO->authorNanme->toString(),
],
Response::HTTP_OK,
);
}
}
Even though we meet the requirements, we see that the controller has plenty of logic: we need to handle the presentation and HTTP code of both the valid response and the errors.
In this post, we will see how to delegate the handling of exceptions to the framework. This way, we will simplify our controllers and centralize the formatting of the error bodies.
Symfony kernel events
From a backend perspective, the flow of an HTTP request consists of receiving a request and returning a response. Symfony’s HttpKernel
is flexible enough to accommodate this flow for all kinds of applications, from microframeworks to full CMS such as Drupal.
Symfony kernel is driven by events, so the logic of the request is done by dispatching events. This makes the flow a bit abstract, but, at the same time, it allows the developer to execute code in concrete hooks of said flow. These hooks are shown in the following image1:
Therefore, we can delegate the exception handling to a hook in the kernel by using a listener or a subscriber. In our case, we chose a subscriber. To decide between both options, there is a comparison in Symfony's documentation.
ExceptionResponseSubscriber
: first version
We can implement an EventSubscriber
that listens to the kernel.exception
event, the place where Symfony manages all unhandled exceptions. The implementation is:
<?php
declare(strict_types=1);
namespace rubenrubiob\Infrastructure\Symfony\Subscriber;
use rubenrubiob\Domain\Exception\Repository\Book\BookDTONotFound;
use rubenrubiob\Domain\Exception\ValueObject\Book\BookIdFormatIsNotValid;
use rubenrubiob\Domain\Exception\ValueObject\Book\BookIdIsEmpty;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Throwable;
use function array_key_exists;
final readonly class ExceptionResponseSubscriber implements EventSubscriberInterface
{
private const EXCEPTION_RESPONSE_HTTP_CODE_MAP = [
BookDTONotFound::class => Response::HTTP_NOT_FOUND,
BookIdFormatIsNotValid::class => Response::HTTP_UNPROCESSABLE_ENTITY,
BookIdIsEmpty::class => Response::HTTP_UNPROCESSABLE_ENTITY,
];
public static function getSubscribedEvents(): array
{
return [KernelEvents::EXCEPTION => ['__invoke']];
}
public function __invoke(ExceptionEvent $event): void
{
$throwable = $event->getThrowable();
$response = new JsonResponse(
[
'error' => $throwable->getMessage(),
],
$this->httpCode($throwable),
[
'Content-Type' => 'application/json',
]
);
$event->setResponse($response);
}
private function httpCode(Throwable $throwable): int
{
$throwableClass = $throwable::class;
if (array_key_exists($throwableClass, self:: EXCEPTION_RESPONSE_HTTP_CODE_MAP)) {
return self:: EXCEPTION_RESPONSE_HTTP_CODE_MAP[$throwableClass];
}
return Response::HTTP_INTERNAL_SERVER_ERROR;
}
}
For each exception within our codebase, we add it to the constant EXCEPTION_RESPONSE_HTTP_CODE_MAP
, where we map the exception to the HTTP that it must return. If the exception is not mapped, we return a 500
HTTP code.
We also format the error body, which in this case is pretty basic: we simply show the exception message2.
Depending on the body of the response, we would need to add more logic to the subscriber, for example, if we use Problem Details or JSON:API3.
If we autoconfigure the services, the subscriber is already registered in our application.
Simplified controller
With the subscriber in place, we can simplify the controller by deleting all the error handling.
<?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,
);
}
}
By delegating the exception handling to a subscriber, we simplify the logic of the controller: we only call the query bus and present the value it returns.
Nonetheless, with this implementation of the subscriber, for each new exception we add to our code that needs a concrete HTTP code, we need to add it to the EXCEPTION_RESPONSE_HTTP_CODE_MAP
. This option does not grow well, as that array
could be so big that it becomes unmanageable. Besides, every developer must remember to add the exception to the map.
Exception interfaces
To solve this issue, we can use an alternative: using interfaces in our domain exceptions.
For example, in this case we can have two interfaces: one for all value object, exceptions, and the other for the not found elements.
<?php
declare(strict_types=1);
namespace rubenrubiob\Domain\Exception\ValueObject;
interface InvalidValueObject
{
}
<?php
declare(strict_types=1);
namespace rubenrubiob\Domain\Exception\Repository;
interface NotFound
{
}
Implementing these interfaces does not imply anything other than adding an implement
to our exceptions because they do not have a body. Therefore, the BookDTONotFound
class can look like this:
<?php
declare(strict_types=1);
namespace rubenrubiob\Domain\Exception\Repository\Book;
use Exception;
use rubenrubiob\Domain\Exception\Repository\NotFound;
use rubenrubiob\Domain\ValueObject\Book\BookId;
use function sprintf;
final class BookDTONotFound extends Exception implements NotFound
{
public static function withBookId(BookId $bookId): self
{
return new self(
sprintf(
'Book with bookId "%s" not found',
$bookId->toString(),
)
);
}
}
ExceptionResponseSubscriber
: second version
With the interfaces implemented in our domain exceptions, we can then refactor the ExceptionResponseSubscriber
:
<?php
declare(strict_types=1);
namespace rubenrubiob\Infrastructure\Symfony\Subscriber;
use rubenrubiob\Domain\Exception\Repository\NotFound;
use rubenrubiob\Domain\Exception\ValueObject\InvalidValueObject;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Throwable;
use function array_key_exists;
use function Safe\class_implements;
final readonly class ExceptionResponseSubscriber implements EventSubscriberInterface
{
private const EXCEPTION_RESPONSE_HTTP_CODE_MAP = [
NotFound::class => Response::HTTP_NOT_FOUND,
InvalidValueObject::class => Response::HTTP_UNPROCESSABLE_ENTITY,
];
public static function getSubscribedEvents(): array
{
return [KernelEvents::EXCEPTION => '__invoke'];
}
public function __invoke(ExceptionEvent $event): void
{
$throwable = $event->getThrowable();
$response = new JsonResponse(
[
'error' => $throwable->getMessage(),
],
$this->httpCode($throwable),
[
'Content-Type' => 'application/json',
]
);
$event->setResponse($response);
}
private function httpCode(Throwable $throwable): int
{
/** @var class-string[] $interfaces */
$interfaces = class_implements($throwable);
foreach ($interfaces as $interface) {
if (array_key_exists($interface, self::RESPONSE_CODE_MAP)) {
return self::EXCEPTION_RESPONSE_HTTP_CODE_MAP[$interface];
}
}
return Response::HTTP_INTERNAL_SERVER_ERROR;
}
}
With only these two interfaces, we avoid having to add each exception that we use in our code. Any exception thrown in a value object or a not found, it will return the correct HTTP code with any other change.
Conclusions
Using interfaces is not a definitive solution, because there could be situations where we want to map a concrete exception to an HTTP code. In any case, it is possible to combine both options, though it would be better to refactor this logic into a service.
As always, the best way is to evolve the code depending on the needs we find along the way.
Summary
- We saw the potential of Symfony's event-driven kernel to simplify our applications.
- We refactored a controller to extract the error handling and delegate it to a subscriber.
- We implemented a subscriber for the
kernel.exception
to handle the exceptions of an API in a centralized way.
-
There is a table with all the kernel events and its parameters in Symfony's official documentation. ↩
-
Showing the exception message in an exception is not usually a good practice because they tend to be messages written by and for developers. Besides, they could contain sensible data that must not be exposed. ↩
-
Check this post by "APIs you won't hate" to learn about different alternatives. ↩
Top comments (0)