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

Jakub Zalas

June 19, 2014
Tweet

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) { } }