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

Skinny controller, skinny model, skinny latte

Sponsored · Ship Features Fearlessly Turn features on and off without deploys. Used by thousands of Ruby developers. →

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

Avatar for Jakub Zalas

Jakub Zalas

June 19, 2014

More Decks by Jakub Zalas

Other Decks in Programming

Transcript

  1. 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] ); } }
  2. 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] ); } }
  3. 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] ); } }
  4. 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] ); }
  5. 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] ); }
  6. 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] ); }
  7. 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] ); } }
  8. 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]; } }
  9. 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]; } }
  10. 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]; } }
  11. <?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>
  12. 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]; } }
  13. class ProductController extends Controller { // ... public function dispatch(Event

    $event) { $this->get('event_dispatcher')->dispatch($event); } }
  14. 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]; } }
  15. 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; } // ... }
  16. 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]; } }
  17. 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]; } }
  18. 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) { } }
  19. 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; } }
  20. /** * @Route(service="shop.controller.product") */ class ProductController { private $dispatcher; private

    $productRepo; public function __construct( EventDispatcherInterface $dispathcer, ProductRepository $productRepo ) { $this->dispatcher = $dispatcher; $this->productRepo = $productRepo; } }
  21. 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]; } }
  22. <?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>
  23. 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
  24. 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
  25. 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
  26. 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
  27. 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
  28. 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
  29. 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
  30. 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
  31. private function getSearchListener() { if (null === $this->searchListener) { throw

    new \LogicException( 'No search listener was attached' ); } return $this->searchListener; } Live coding
  32. 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
  33. 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
  34. class ProductController implements SearchListener { // ... /** * @Route("/search",

    name="search") * @Template */ public function searchAction(SearchQuery $searchQuery) { $this->searchUseCase->search($searchQuery); return $this->response; } // ... } Live coding
  35. 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
  36. class ProductController implements SearchListener { // ... /** * @param

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

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

    SearchQuery $searchQuery * * @return null */ public function onNoSearchResults(SearchQuery $searchQuery) { $this->httpFacade->setTemplate( 'SensioLabsShopBundle:Product:noresults.html.twig' ); } // ... } Live coding
  39. 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
  40. class HttpFacade { // ... /** * @param string $template

    */ public function setTemplate($template) { $this->getRequestAttributes()->set( '_template', $template ); } } Live coding
  41. 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
  42. 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
  43. class HttpFacade { // ... /** * @return ParameterBag */

    private function getRequestAttributes() { return $this->getRequest()->attributes; } /** * @return null|Request */ private function getRequest() { return $this->requestStack->getCurrentRequest(); } } Live coding
  44. 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); } // ... }
  45. class SearchCommand extends ContainerAwareCommand implements SearchListener { // ... public

    function onNoKeywords() { } public function onSearchResults(array $products) { } public function onBrandFound(Brand $brand) { } public function onNoSearchResults(SearchQuery $searchQuery) { } }