Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Skinny controller, skinny model, skinny latte

Skinny controller, skinny model, skinny latte

An experiment on how an application logic could be truely decoupled from framework controllers, presented on the Inviqa Developers Day in London.

Warning: This is an experiment, never used on a real project and probably requires few more iterations to make it usable.

#inviqadevday #Symfony2 #phpspec

1a4e1f98f3aeef310273366c8c785207?s=128

Jakub Zalas

June 19, 2014
Tweet

Transcript

  1. Skinny controller, skinny model, skinny latte Jakub Zalas Inviqa Dev

    Day 19th June 2014
  2. MVC IN THE WEB WORLD

  3. Controller View Model Request

  4. Controller View Model Request Where is Doctrine on this picture?

    * it's a detail
  5. WE WILL KEEP IT SIMPLE Skinny controller promise

  6. namespace SensioLabs\ShopBundle\Controller; use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route; use Symfony\Component\HttpFoundation\Request; class ProductController

    extends Controller { /** * @Route("/", name="search") */ public function searchAction(Request $request) { $keywords = $request->query->get('keywords'); $products = $this->getDoctrine() ->getRepository('SensioLabsShopBundle:Product') ->search($keywords); return $this->render( 'SensioLabsShopBundle:Product:search.html.twig', ['products' => $products] ); } }
  7. THE CHANGE Let's add sorting and filtering by brands!

  8. class ProductController extends Controller { /** * @Route("/", name="search") */

    public function searchAction(Request $request) { $keywords = $request->query->get('keywords'); $products = $this->getDoctrine() ->getRepository('SensioLabsShopBundle:Product') ->search($keywords); return $this->render( 'SensioLabsShopBundle:Product:search.html.twig', ['products' => $products] ); } }
  9. class ProductController extends Controller { /** * @Route("/", name="search") */

    public function searchAction(Request $request) { $keywords = $request->query->get('keywords'); $sort = $request->query->get('sort', 'ASC'); $brandName = $request->query->get('brand'); $products = $this->getDoctrine() ->getRepository('SensioLabsShopBundle:Product') ->search($keywords, $sort, $brandName); return $this->render( 'SensioLabsShopBundle:Product:search.html.twig', ['products' => $products] ); } }
  10. MORE CHANGE Let's redirect to a brand page if its

    name matches keywords!
  11. public function searchAction(Request $request) { $keywords = $request->query->get('keywords'); $sort =

    $request->query->get('sort', 'ASC'); $brandName = $request->query->get('brand'); $products = $this->getDoctrine() ->getRepository('SensioLabsShopBundle:Product') ->search($keywords, $sort, $brandName); return $this->render( 'SensioLabsShopBundle:Product:search.html.twig', ['products' => $products] ); }
  12. public function searchAction(Request $request) { $keywords = $request->query->get('keywords'); $sort =

    $request->query->get('sort', 'ASC'); $brandName = $request->query->get('brand'); $brand = $this->getDoctrine() ->getRepository('SensioLabsShopBundle:Brand') ->findOneByName($keywords); if ($brand) { return $this->redirect($this->generateUrl( 'brand', ['name' => $brand->getName()] )); } $products = $this->getDoctrine() ->getRepository('SensioLabsShopBundle:Product') ->search($keywords, $sort, $brandName); return $this->render( 'SensioLabsShopBundle:Product:search.html.twig', ['products' => $products] ); }
  13. EVEN MORE CHANGE DB is not enough, we need elastic

    search!
  14. None
  15. What belongs to the controller?

  16. public function searchAction(Request $request) { $keywords = $request->query->get('keywords'); $sort =

    $request->query->get('sort', 'ASC'); $brandName = $request->query->get('brand'); $brand = $this->getDoctrine() ->getRepository('SensioLabsShopBundle:Brand') ->findOneByName($keywords); if ($brand) { return $this->redirect($this->generateUrl( 'brand', ['name' => $brand->getName()] )); } $products = $this->getDoctrine() ->getRepository('SensioLabsShopBundle:Product') ->search($keywords, $sort, $brandName); return $this->render( 'SensioLabsShopBundle:Product:search.html.twig', ['products' => $products] ); }
  17. APPLICATION LOGIC LEAKED INTO THE CONTROLLER

  18. WE STARTED FROM THE WRONG END

  19. SKINNY CONTROLLERS (standard techniques)

  20. @TEMPLATE

  21. class ProductController extends Controller { /** * @Route("/", name="search") */

    public function searchAction(Request $request) { $keywords = $request->query->get('keywords'); $products = $this->getDoctrine() ->getRepository('SensioLabsShopBundle:Product') ->search($keywords); return $this->render( 'SensioLabsShopBundle:Product:search.html.twig', ['products' => $products] ); } }
  22. class ProductController extends Controller { /** * @Route("/", name="search") *

    @Template */ public function searchAction(Request $request) { $keywords = $request->query->get('keywords'); $products = $this->getDoctrine() ->getRepository('SensioLabsShopBundle:Product') ->search($keywords); return ['products' => $products]; } }
  23. REPOSITORY AS A SERVICE

  24. class ProductController extends Controller { /** * @Route("/", name="search") *

    @Template */ public function searchAction(Request $request) { $keywords = $request->query->get('keywords'); $products = $this->getDoctrine() ->getRepository('SensioLabsShopBundle:Product') ->search($keywords); return ['products' => $products]; } }
  25. class ProductController extends Controller { /** * @Route("/", name="search") *

    @Template */ public function searchAction(Request $request) { $keywords = $request->query->get('keywords'); $products = $this->get('shop.repository.product') ->search($keywords); return ['products' => $products]; } }
  26. <?xml version="1.0" ?> <container xmlns="http://symfony.com/schema/dic/services" ...> <services> <service id="shop.repository.product" class="SensioLabs\ProductCatalog\ProductRepository"

    factory-service="doctrine" factory-method="getRepository"> <argument>Shop:Product</argument> </service> </services> </container>
  27. EVENT DISPATCHER

  28. class ProductController extends Controller { /** * @Route("/", name="search") *

    @Template */ public function searchAction(Request $request) { $keywords = $request->query->get('keywords'); $this->dispatch(new SearchEvent($keywords)); $products = $this->get('shop.repository.product') ->search($keywords); $this->dispatch(new SearchResultsEvent( $keywords, $products )); return ['products' => $products]; } }
  29. class ProductController extends Controller { // ... public function dispatch(Event

    $event) { $this->get('event_dispatcher')->dispatch($event); } }
  30. class ProductController extends Controller { /** * @Route("/", name="search") *

    @Template */ public function searchAction(Request $request) { $keywords = $request->query->get('keywords'); $event = new SearchEvent($keywords); $this->dispatch($event); if ($event->hasResponse()) { return $event->getResponse(); } $products = $this->get('shop.repository.product') ->search($keywords); // ... return ['products' => $products]; } }
  31. PARAM CONVERTER

  32. class SearchQuery { private $keywords; private $sort; private $brand; public

    function __construct($keywords, $sort = 'ASC', $brand) { $this->keywords = $keywords; $this->sort = $sort; $this->brand = $brand; } public function hasKeywords() { return null !== $this->keywords; } public function getKeywords() { return $this->keywords; } // ... }
  33. class ProductController extends Controller { /** * @Route("/", name="search") *

    @Template */ public function searchAction(Request $request) { $keywords = $request->query->get('keywords'); $this->dispatch(new SearchEvent($keywords)); $products = $this->get('shop.repository.product') ->search($keywords); $this->dispatch(new SearchResultsEvent( $keywords, $products )); return ['products' => $products]; } }
  34. class ProductController extends Controller { /** * @Route("/", name="search") *

    @ParamConverter( * name="searchQuery", * converter="search_query" * ) * @Template */ public function searchAction(SearchQuery $searchQuery) { $this->dispatch(new SearchEvent($serachQuery)); $products = $this->get('shop.repository.product') ->search($searchQuery); $this->dispatch(new SearchResultsEvent( $searchQuery, $products )); return ['products' => $products]; } }
  35. namespace SensioLabs\ShopBundle\Request\ParamConverter; use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter; use Sensio\Bundle\FrameworkExtraBundle\Request\ParamConverter\ParamConverterInterface; use SensioLabs\ProductCatalog\SearchQuery; use Symfony\Component\HttpFoundation\Request;

    class SearchQueryConverter implements ParamConverterInterface { public function apply( Request $request, ParamConverter $configuration ) { } public function supports(ParamConverter $configuration) { } }
  36. class SearchQueryConverter implements ParamConverterInterface { public function apply( Request $request,

    ParamConverter $configuration ) { $searchQuery = new SearchQuery( $request->query->get('keywords'), $request->query->get('sort'), $request->query->get('brand') ); $request->attributes->set( $configuration->getName(), $searchQuery ); } public function supports(ParamConverter $configuration) { return $configuration->getClass() === SearchQuery::class; } }
  37. <?xml version="1.0" ?> <container xmlns="http://symfony.com/schema/dic/services" ...> <services> <service id="sensiolabs_shop.param_converter.search_query" class="SensioLabs\ShopBundle\Request\ParamConverter\SearchQueryConverter">

    <tag name="request.param_converter" converter="search_query" /> </service> </services> </container>
  38. CONTROLLER AS A SERVICE

  39. /** * @Route(service="shop.controller.product") */ class ProductController { private $dispatcher; private

    $productRepo; public function __construct( EventDispatcherInterface $dispathcer, ProductRepository $productRepo ) { $this->dispatcher = $dispatcher; $this->productRepo = $productRepo; } }
  40. class ProductController { /** * @Route("/", name="search") * @ParamConverter( *

    name="searchQuery", * converter="shop.converter.search_query" * ) * @Template */ public function searchAction(SearchQuery $searchQuery) { $this->dispatcher->dispatch( new SearchEvent($serachQuery) ); $products = $this->productRepo->search($searchQuery); $this->dispatcher->dispatch(new SearchResultsEvent( $searchQuery, $products )); return ['products' => $products]; } }
  41. <?xml version="1.0" ?> <container xmlns="http://symfony.com/schema/dic/services" ...> <services> <service id="shop.controller.product" class="SensioLabs\ShopBundle\Controller\ProductController">

    <argument type="service" id="event_dispatcher" /> <argument type="service" id="shop.repository.product" /> </service> </services> </container>
  42. NOT GOOD ENOUGH!

  43. Framework Infrastructure

  44. GROUP THINGS THAT CHANGE TOGETHER

  45. Framework Model

  46. LET'S START OVER

  47. FOCUSING ON THE APPLICATION FIRST

  48. None
  49. LIVE CODING TIME!

  50. SPECS! Live coding

  51. namespace spec\SensioLabs\Shop\UseCase; use PhpSpec\ObjectBehavior; use SensioLabs\ProductCatalog\Brand; use SensioLabs\ProductCatalog\BrandRepository; use SensioLabs\ProductCatalog\Product;

    use SensioLabs\ProductCatalog\ProductRepository; use SensioLabs\ProductCatalog\SearchQuery; class SearchUseCaseSpec extends ObjectBehavior { function let( ProductRepository $productRepository, BrandRepository $brandRepository, SearchListener $searchListener, SearchQuery $searchQuery ) { $this->beConstructedWith( $productRepository, $brandRepository); $this->append($searchListener); $searchQuery->hasKeywords()->willReturn(true); } } Live coding
  52. function it_notifies_onNoKeywords_if_there_is_no_keywords ( SearchListener $searchListener, SearchQuery $searchQuery ) { $searchQuery->hasKeywords()->willReturn(false);

    $searchListener->onNoKeywords()->shouldBeCalled(); $this->search($searchQuery); } Live coding
  53. function it_notifies_onSearchResults_if_products_are_found( SearchListener $searchListener, SearchQuery $searchQuery, ProductRepository $productRepository, Product $product

    ) { $productRepository->search($searchQuery) ->willReturn([$product]); $searchListener->onSearchResults([$product]) ->shouldBeCalled(); $this->search($searchQuery); } Live coding
  54. function it_notifies_onBrandFound_if_brand_is_found( SearchListener $searchListener, SearchQuery $searchQuery, BrandRepository $brandRepository, Brand $brand

    ) { $brandRepository->search($searchQuery)->willReturn($brand); $searchListener->onBrandFound($brand)->shouldBeCalled(); $this->search($searchQuery); } Live coding
  55. function it_notifies_onNoSearchResults_if_no_product_was_found( SearchListener $searchListener, SearchQuery $searchQuery, ProductRepository $productRepository ) {

    $productRepository->search($searchQuery)->willReturn([]); $searchListener->onNoSearchResults($searchQuery) ->shouldBeCalled(); $this->search($searchQuery); } Live coding
  56. THE LISTENER Live coding

  57. namespace SensioLabs\Shop\UseCase; interface SearchListener { public function onNoKeywords(); public function

    onSearchResults(array $products); public function onBrandFound(Brand $brand); public function onNoSearchResults(SearchQuery $searchQuery); } The listener interface defines use case events Live coding
  58. THE USE CASE / INTERACTOR Live coding

  59. namespace SensioLabs\Shop\UseCase; use SensioLabs\ProductCatalog\BrandRepository; use SensioLabs\ProductCatalog\ProductRepository; use SensioLabs\ProductCatalog\SearchQuery; class SearchUseCase

    { /** * @var ProductRepository */ private $productRepository; /** * @var BrandRepository */ private $brandRepository; /** * @var SearchListener */ private $searchListener; // ... } Live coding
  60. namespace SensioLabs\Shop\UseCase; use SensioLabs\ProductCatalog\BrandRepository; use SensioLabs\ProductCatalog\ProductRepository; use SensioLabs\ProductCatalog\SearchQuery; class SearchUseCase

    { // ... public function __construct( ProductRepository $productRepository, BrandRepository $brandRepository ) { $this->productRepository = $productRepository; $this->brandRepository = $brandRepository; } public function append(SearchListener $searchListener) { $this->searchListener = $searchListener; } // ... } Live coding
  61. public function search(SearchQuery $searchQuery) { if (!$searchQuery->hasKeywords()) { $this->getSearchListener()->onNoKeywords(); return;

    } if ($brand = $this->brandRepository->search($searchQuery)) { $this->getSearchListener()->onBrandFound($brand); return; } $products = $this->productRepository->search($searchQuery); if ($products) { $this->getSearchListener()->onSearchResults($products); return; } $this->getSearchListener()->onNoSearchResults($searchQuery); } Live coding
  62. private function getSearchListener() { if (null === $this->searchListener) { throw

    new \LogicException( 'No search listener was attached' ); } return $this->searchListener; } Live coding
  63. THE CONTROLLER Live coding

  64. namespace SensioLabs\ShopBundle\Controller; // use omitted /** * @Route(service="sensiolabs_shop.controller.product") */ class

    ProductController implements SearchListener { /** * @var SearchUseCase */ private $searchUseCase; /** * @var HttpFacade */ private $httpFacade; private $response; // ... } Live coding
  65. class ProductController implements SearchListener { // ... /** * @param

    SearchUseCase $searchUseCase * @param HttpFacade $httpFacade */ public function __construct( SearchUseCase $searchUseCase, HttpFacade $httpFacade ) { $this->searchUseCase = $searchUseCase; $this->searchUseCase->attach($this); $this->httpFacade = $httpFacade; } // ... } Live coding
  66. class ProductController implements SearchListener { // ... /** * @Route("/search",

    name="search") * @Template */ public function searchAction(SearchQuery $searchQuery) { $this->searchUseCase->search($searchQuery); return $this->response; } // ... } Live coding
  67. class ProductController implements SearchListener { // ... /** * @param

    Brand $brand * * @return null */ public function onBrandFound(Brand $brand) { $this->response = new RedirectResponse( $this->httpFacade->generate( 'brand', ['slug' => $brand->getSlug()] ) ); } // ... } Live coding
  68. class ProductController implements SearchListener { // ... /** * @param

    Product[] $products * * @return null */ public function onSearchResults(array $products) { $this->httpFacade->setTemplateVars( ['products' => $products] ); } // ... } Live coding
  69. class ProductController implements SearchListener { // ... /** * @return

    null */ public function onNoKeywords() { $this->httpFacade->setTemplate( 'SensioLabsShopBundle:Product:nokeywords.html.twig' ); } // ... } Live coding
  70. class ProductController implements SearchListener { // ... /** * @param

    SearchQuery $searchQuery * * @return null */ public function onNoSearchResults(SearchQuery $searchQuery) { $this->httpFacade->setTemplate( 'SensioLabsShopBundle:Product:noresults.html.twig' ); } // ... } Live coding
  71. THE HTTP FAÇADE HELPER Live coding

  72. namespace SensioLabs\ShopBundle\Controller; // ... use statements omitted class HttpFacade {

    /** * @var RequestStack */ private $requestStack; /** * @var Router */ private $router; public function __construct( RequestStack $requestStack, Router $router ) { $this->requestStack = $requestStack; $this->router = $router; } } Live coding
  73. class HttpFacade { // ... /** * @param string $template

    */ public function setTemplate($template) { $this->getRequestAttributes()->set( '_template', $template ); } } Live coding
  74. class HttpFacade { // ... /** * @param array $vars

    */ public function setTemplateVars(array $vars) { $this->getRequestAttributes()->set( '_template_vars', array_keys($vars) ); foreach ($vars as $name => $value) { $this->getRequestAttributes()->set($name, $value); } } } Live coding
  75. class HttpFacade { // ... /** * @param string $name

    * @param array $parameters * @param bool $referenceType * * @return string */ public function generateUrl( $name, array $parameters = [], $referenceType = Router::ABSOLUTE_PATH ) { return $this->router->generate( $name, $parameters, $referenceType ); } } Live coding
  76. class HttpFacade { // ... /** * @return ParameterBag */

    private function getRequestAttributes() { return $this->getRequest()->attributes; } /** * @return null|Request */ private function getRequest() { return $this->requestStack->getCurrentRequest(); } } Live coding
  77. How to implement a search command?

  78. class SearchCommand extends ContainerAwareCommand implements SearchListener { // ... protected

    function execute( InputInterface $input, OutputInterface $output ) { $searchUseCase = $this->getContainer() ->get('sensiolabs_shop.use_case.search'); $searchUseCase->attach($this); $keywords = $input->getArgument('keywords'); $searchQuery = new SearchQuery($keywords); $products = $searchUseCase->search($searchQuery); } // ... }
  79. class SearchCommand extends ContainerAwareCommand implements SearchListener { // ... public

    function onNoKeywords() { } public function onSearchResults(array $products) { } public function onBrandFound(Brand $brand) { } public function onNoSearchResults(SearchQuery $searchQuery) { } }
  80. THE IDEA IS A WIP ;)

  81. QUESTIONS ?