Back-End

7 jun, 2017

Controllers e Exceções de Domínio

Publicidade

Alguns meses atrás, eu tive uma conversa muito bacana, por e-mail, sobre como lidar com exceções de lógica empresarial em seu código controller. A mensagem dele segue abaixo, levemente editada para fins de brevidade e claridade:

—-

Acho que o controller tem responsabilidade única – para mediar a comunicação entre o contexto do caller e serviços de lógica empresarial real. (Acredito que os serviços de lógica empresarial não devem estar cientes do contexto do caller, seja ele uma solicitação HTTP ou CLI.)

Dado que os serviços não devem saber quem os chamou, eles não devem lançar exceções específicas do HTTP. Assim, ao invés de lançar uma Exceção HttpNotFound do Symfony, o serviço lançaria ObjectNotFound (que é HTTP agnóstico) nos casos em que o registro do banco de dados não pudesse ser encontrado.

No entanto, ao mesmo tempo, a lógica que converte exceções a respostas HTTP espera exceções Symfony específicas do HTTP. Isso significa que a exceção lançada pelo serviço precisa ser transformada em exceção do Symfony.

Uma das soluções que vejo para isso é que o controller pode assumir essa responsabilidade. Capturaria exceções de domínio lançadas pelo serviço e as envolveria em exceções HTTP apropriadas.

class FooController
{
    public function fooAction()
    {
        try {
            $this->service->doSomething('foo');
        } catch (ObjectNotFound $e) {
            throw new NotFoundHttpException('Not Found', $e);
        } catch (InvalidDataException $e) {
            throw new BadRequestHttpException('Invalid value', $e);
        } // ...
    }
}

A desvantagem que eu vejo com esta abordagem é que, se eu tenho muitos controllers, vou ter duplicação de código. Isso também pode levar a grande quantidade de catch em blocos, por causa de muitas exceções possíveis que poderiam ser lançadas.

Outra abordagem seria não fazer try/catch de blocos no controller e deixar as exceções lançadas pelo serviço levantarem a pilha, deixando a manipulação de exceção para o handler de exceção. Essa abordagem solucionaria o problema de duplicação de código e muitos problemas de try/catch de blocos. No entanto, como o builder de respostas aceita apenas as exceções do Symfony, elas precisam ser mapeadas em algum lugar.

Também me parece que, desta forma, o controller é feito de modo mais limpo, mas parte da responsabilidade dos controllers é delegada a outra coisa, rompendo o encapsulamento. Eu sinto que é trabalho dos controllers decidir qual status code deve ser sintonizado novamente em cada caso, mas ao mesmo tempo, os casos geralmente são os mesmos.

Eu realmente espero que você seja capaz de compartilhar seus pensamentos sobre isso e as maneiras com as quais você iria enfrentar isso.

—-

Caso você encontre-se nessa situação, a primeira pergunta a se fazer é: “Por que estou lidando com exceções de domínio em meu código de interface de usuário?” (Lembre-se: Model-View-Controller e Action-Domain-Responder são padrões de interface de usuário; neste caso, a interface de usuário é composta de uma solicitação HTTP e resposta.) As exceções de domínio devem ser tratadas pela lógica de domínio de forma apropriada ao domínio.

A primeira intuição do meu correspondente (usando exceções no nível do domínio, não as específicas do HTTP) tem o espírito correto. No entanto, em vez de ter o serviço de domínio lançando exceções para o controlador de interface do usuário para pegar e manipular, sugiro que o serviço retorne um Domínio Payload com relatório de status específico do domínio. Em seguida, a interface do usuário pode inspecionar o Domínio Payload para determinar como responder. Eu me aprofundo nessa abordagem neste artigo.

A título de exemplo, em vez disso no seu controller…

class FooController
{
    public function fooAction()
    {
        try {
            $this->service->doSomething('foo');
        } catch (ObjectNotFound $e) {
            throw new NotFoundHttpException('Not Found', $e);
        } catch (InvalidDataException $e) {
            throw new BadRequestHttpException('Invalid value', $e);
        } // ...
    }
}

…tente algo mais parecido com isto:

class FooController
{
    public function fooAction()
    {
        $payload = $this->service->doSomething('foo');
        switch ($payload->getStatus()) {
            case $payload::OBJECT_NOT_FOUND:
                throw new NotFoundHttpException($payload->getMessage());
            case $payload::INVALID_DATA:
                throw new BadRequestHttpException($payload->getMessage());
            // ...
        }
    }
}

(Eu não sou um fã de usar exceções para gerenciar o controle de fluxo; eu prefiro retornar um novo objeto Response. No entanto, estou tentando ficar o mais perto do exemplo original quanto possível, para que as diferenças sejam mais facilmente examinadas).

A ideia aqui é manter a lógica de domínio na camada de domínio (neste caso, um serviço). O serviço deve validar a entrada e, se falhar, devolver um payload “não válido”. O serviço deve capturar todas as exceções e retornar um payload que descreve o tipo de erro que ocorreu. Você pode, então, refinar seu controller para examinar o Domain Payload fazer o que quiser com isso.

O uso de um Domain Payload dessa maneira não é um grande salto. Essencialmente, você muda de blocos try/catch e classes de exceção para um bloco switch/case e constantes de status. O que é importante é que agora você está lidando com exceções Domain Level no domínio e não na camada de interface do usuário. Você também está encapsulando as informações de status relatadas pelo domínio, para que você possa transferir o objeto Domain Payload do controller para outra coisa inspecionar e lidar.

O Encapsulamento via Domain Payload abre o caminho para uma refatoração mais significativa que ajudará a reduzir a repetição da lógica response-building em muitas ações do controller. Essa próxima refatoração é separar o trabalho do response-building para um Responder e usar o Responder na ação do controller para retornar uma resposta. Você pode, então, passar o Domain Payload para o Responder para que ele manipule.

class FooController
{
    public function fooAction()
    {
        $payload = $this->service->doSomething('foo');
        return $this->responder->respond($payload);
    }
}

class FooResponder
{
    public function respond($payload)
    {
        switch ($payload->getStatus()) {
            case $payload::OBJECT_NOT_FOUND:
                throw new NotFoundHttpException('Not Found', $e);
            case $payload::INVALID_DATA:
                throw new BadRequestHttpException('Invalid value', $e);
            // ...
        }
    }
}

Depois de fazer isso, você perceberá que a maioria de sua lógica de construção de respostas pode entrar em um Responder comum ou de base. Casos personalizados podem, então, ser manipulados por Responders controller ou de formato específico.

E, então, você perceberá que toda sua lógica de Ação é praticamente a mesma: colete input da Request, passe este input para uma layer de domain-service para recuperar um resultado de Domain Payload e repasse esse resultado para um Responder para recuperar uma resposta. Nesse ponto, você será capaz de se livrar de controllers inteiramente, em favor de um único action handler padronizado.

 

***

Paul M. Jones faz parte do time de colunistas internacionais do iMasters. A tradução do artigo é feita pela Redação iMasters, com autorização do autor, e você pode acompanhar o artigo em inglês no link: http://paul-m-jones.com/archives/6608