Refactorizando controladores: Separando responsabilidades

Una de las cosas más complicadas para los programadores es tomar consciencia sobre las como modelar situaciones y objetos del mundo real y aplicarlos al mundo de la programación. De vez en cuando, es necesario dar un paso atrás y pensar en lo que se ha estado haciendo.

Así es como al final, acabamos dándonos cuenta de que llenar un controlador de lógica es como crear un monstruo. Últimamente he leído mucho sobre refactoring y más de un post hablando sobre que llenar el controlador de código es algo malo, así que hoy vamos a ver de primera mano como solucionarlo.

Pongamos como ejemplo un controlador como este:

Seguir leyendo “Refactorizando controladores: Separando responsabilidades”

Skinny Controller: moviendo la lógica del controller

Cuando empecé a utilizar PHP, yo incluía bastante cantidad de lógica en el controller, entonces empecé a leer sobre refactoring y me topé con skinny controller fat models, el código de mis controlllers se reducía bastante y la lógica estaba en los modelos, pero lo único que hacía era mover el problema de sitio. Ahora tenía modelos muy grandes. Cuando una action interactúa con más de un modelo, me encontraba en que no sabía en que model escribir esa lógica…

El código en los controller no puede reutilizarse, por lo que en algunos casos como en ‘pedidos/Order’ en los que hay que lidiar con los descuentos, los artículos del la orden, la dirección,… escribir toda lógica de este proceso en un controlador no es la solución ideal, ya que modificar toda esa lógica hará que tengamos código duplicado.

Así que lo mejor es abstraer toda la lógica del proceso en unas clases de servicio. Así estaba el controller antes de crear el servicio:

<?php 
  class OrdersController extends Controller {

    public function actionCreate() {
      try {
        $orderData  = Yii::app()->request->getParam('order');

        if(empty($orderData['items'])) {
          $this->_sendResponse(403, array(
            'status' => 'error', 'message' => 'Can\'t save order without items'
          ));
        }
        $items = $orderData['items'];
        unset($orderData['items']);
        try {
          $order = new Orders;
          $orderTransaction = $order->dbConnection->beginTransaction();
          if($order) {
            $address = Addresses::createIfDidntExist($orderData);
            unset($orderData['address']);
            $orderData['address_id'] = $address->id;
            $amount = 0;
            foreach ($items as $key => $item) {
              $amount += $item['total'];
            }
            $amount += $orderData['extra_charge'];
            $orderData['amount'] = $amount;
            $order->attributes = $orderData;
            if($order->save()) {
              if(OrderItems::batchSave($items, $order->id)) {
                $orderTransaction->commit();
                $this->sendMail($order->id);
                $this->_sendResponse(200, array(
                  'status' => 'success', 'message' => 'Order placed successfully.'
                ));
              }
              $orderTransaction->rollback();
              $this->_sendResponse(403, array(
                'status' => 'error', 'message' => 'Order creation failed'
              ));
            }
            else {
              $orderTransaction->rollback();
              $this->_sendResponse(403, array(
                'status' => 'error', 'errors' => $order->getErrors()
              ));
            }
          }
        }
        catch(Exception $e) {
          $orderTransaction->rollback();
          $this->_sendResponse(403, array(
            'status' => 'error', 'message' => $e->getMessage()
          ));
        }
      }
      catch(Exception $e) {
        $this->_sendResponse(403, array(
          'status' => 'error', 'message' => $e->getMessage()
        ));
      }
    }

  public function sendMail($order_id) {
    // logic to send email after placing an order successfully
  }

?>

Toda la lógica y las excepciones están en el controlador y no es posible volver a utilizarla en otra acción. También tenemos un código muy difícil de probar, así que lo mejor es mudar a una clase OrderService:

<?php

class OrdersService {
  public function create($orderData) {

    if(empty($orderData['items'])) {
      throw new OrdersServiceException('Order items can\'t be empty.');
    }
    $items = $orderData['items'];
    unset($orderData['items']);
    try {
      $order = new Orders;
      $orderTransaction = $order->dbConnection->beginTransaction();

      $address = Addresses::createIfDidntExist($orderData);
      unset($orderData['address']);
      $orderData['address_id'] = $address->id;
      $amount = 0;
      foreach ($items as $key => $item) {
        $amount += $item['total'];
      }
      $amount += $orderData['extra_charge'];
      $orderData['amount'] = $amount;
      $order->attributes = $orderData;
      if($order->save()) {
        if(OrderItems::batchSave($items, $order->id)) {
          $orderTransaction->commit();
          $this->sendMail($order->id);
          return array('status' => 'success');
        }
        $orderTransaction->rollback();
        throw new OrdersServiceException("Failed to save the items.", 1);
      }
      else {
        // handle validation errors
        $orderTransaction->rollback();
        return array('status' => 'error', 'errors' => $order->getErrors());
      }
    }
    catch(Exception $e) {
      $orderTransaction->rollback();
      throw new Exception('Something wrong happened');
    }
  }

  public function sendMail($order_id) {
    // logic to send email after placing an order successfully
  }
}

class OrdersException extends Exception {

}
?>

Ahora se está lanzando una excepción para todos los errores. Así que se puede capturar la excepción en cualquier lugar en el que utilicemos el servicio y mostrar los errores dependiendo de acción (un mensaje de error, un JSON,…)

Una vez que hemos mudado todo a la clase de servicio, tenemos un controlador como este:

<?php 
  class OrdersController extends Controller {
    public function actionCreate() {
      $orderData = Yii::app()->request->getParam('order');
      try {
        $order = new OrdersService();
        $res = $order->create($orderData);
        if(isset($res['status']) && $res['status'] == 'success') {
            $res['message'] = 'Order placed successfully.';
            $this->_sendResponse(200, $res);
        }
        $this->_sendResponse(403, $res);
      }
      catch(OrdersServiceException $e) {
        $this->_sendResponse(403, array(
          'status' => 'error', 'message' => $e->getMessage()
        ));
      }
      catch(Exception $e) {
        $this->_sendResponse(403, array(
          'status' => 'error', 'message' => $e->getMessage()
        ));
      }
    }
  }
?>

Ahora el controller solo tiene el código necesario que necesita esta acción. No tenemos que reutilizar nada de esto, ya que la forma de mostrar el error depende de la action particular.

Referencias

Esta entrada esta basada en el artículo http://blog.revathskumar.com/2015/08/php-service-classes.html

Skinny Controller: Servicios y Action-Domain-Responder

Hace unos días estuvimos viendo como hacer skinny controllers extrayendo toda la lógica de los controladores a servicios que pueden ser reutilizados. Después de leer el artículo original Paul M. Jones autor de “Modernizing Legacy application in PHP” escribió un post al respecto introduciendo el patrón Action-Domain-Responder.

Así que hoy vamos a traducir el artículo http://paul-m-jones.com/archives/6172 en el que Paul describe el trabajo que extra necesario para aplicar el patrón ADR al ejemplo anterior.

Utilizando el código del post anterior vamos a trabajar en los siguientes puntos:

  • En la clase de servicio, en lugar de lanzar excepciones y a veces devolver arrays, nosotros siempre vamos a devolver instancias de Payload. Estos establecen explicitamente el resultado de la actividad del dominio (“Entrada no valida”, “order creada”, “order no creada”, “error”). Tener de manera explicita la información del estado en “Payload” significa que no necesitamos capturar excepciones en la acción del controlador, y que no es necesario examinar los objetos del dominio para interpretar lo que ocurrió en el dominio. La clase de servicio puede ahora decir explicitamente lo que pasó de una manera estandarizada.
  • En la acción del controller, ahora como ya no necesitamos capturar las excepciones, podemos concentrarnos en un conjunto mucho más pequeño de lógica: obtener la entrada del usuario, pasarlo al dominio, recuperar el dominio de Payload y pasar el Payload al responder.
  • Por último, introducimos un Responder, cuyo trabajo consiste en construir (y en este caso de envío) la respuesta. La lógica de respuesta termina simplificándose también.

El código modificado es más o menos como este:

  • Controller
use Aura\Payload\Payload;

class OrdersController extends Controller {
  public function actionCreate() {
    $orderData = Yii::app()->request->getParam('order');
    $order = new OrdersService();
    $payload = $order->create($orderData);
    $responder = new OrdersResponder();
    $responder->sendCreateResponse($payload);
  }
}
  • Responder:
class OrdersResponder extends Responder {
  public function sendCreateResponse($payload) {
    $result = array('messages' => $payload->getMessages());
    if ($payload->getStatus() === Payload::CREATED) {
      $this->_sendResponse(200, $result);
    } else {
      $result['status'] = 'error';
      $this->_sendResponse(403, $result);
    }
  }
}
  • OrderService:
class OrdersService {

  protected function newPayload($status) {
    return new Payload($status);
  }

  public function create($orderData) {
    if(empty($orderData['items'])) {
      return $this->newPayload(Payload::NOT_VALID)
        ->setMessages([
          "Order items can't be empty."
        ]);
    }
    $items = $orderData['items'];
    unset($orderData['items']);
    try {
      $order = new Orders;
      $orderTransaction = $order->dbConnection->beginTransaction();

      $address = Addresses::createIfDidntExist($orderData);
      unset($orderData['address']);
      $orderData['address_id'] = $address->id;
      $amount = 0;
      foreach ($items as $key => $item) {
        $amount += $item['total'];
      }
      $amount += $orderData['extra_charge'];
      $orderData['amount'] = $amount;
      $order->attributes = $orderData;
      if($order->save()) {
        if(OrderItems::batchSave($items, $order->id)) {
          $orderTransaction->commit();
          $this->sendMail($order->id);
          return $this->newPayload(Payload::CREATED)
            ->setMessages([
              "Order placed successfully."
            ]);
        }
        $orderTransaction->rollback();
        return $this->newPayload(Payload::NOT_CREATED)
          ->setMessages([
            "Failed to save the items."
          ]);
      }
      else {
        // handle validation errors
        $orderTransaction->rollback();
        return $this->newPayload(Payload::ERROR)
          ->setMessages($order->getErrors());
      }
    }
    catch(Exception $e) {
      $orderTransaction->rollback();
      return $this->newPayload(Payload::ERROR)
        ->setMessages([
          "Something wrong happened"
        ]);
    }
  }
}

Ahora, todavía hay mucho que mejorar aquí. Por ejemplo, podríamos empezar a separar los métodos de action del controlador en sus propias clases individuales. Pero tendremos que ir dando pequeños pasos para llegar a esa refactorización.

Este pequeño conjunto de cambios nos da una mejor separación de las preocupaciones, especialmente en lo que se refiere a la presentación. Recordemos que la “presentación” en un entorno de “peticionesrespuesta” es toda las respuestas HTTP, no solo el cuerpo de las respuestas. Los cambios anteriores hacen que las cabeceras HTTP y la presentación ya no se mezclen en el controlador, ahora son manejadas por el objeto Responder.