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

Confoo Vancouver - Design Patterns

Hugo Hamon
December 06, 2017

Confoo Vancouver - Design Patterns

Design patterns are conceptual solutions to solve common redundant problems in software engineering. However, learning them is not easy as litterature or tutorials on the Internet often introduce them with theorical examples. This talk gives you a slightly different approach by introducing design patterns with practical code samples to solve real world problems. The talk focuses on the Abstract Factory, Builder, Decorator and Composite patterns.

Hugo Hamon

December 06, 2017
Tweet

More Decks by Hugo Hamon

Other Decks in Programming

Transcript

  1. Solving Real World Problems with Design Patterns. Confoo 2017 /

    Dec. 6th / Vancouver / Canada Hugo Hamon h"ps://www.flickr.com/photos/maelick/15302490013/  
  2. Hugo Hamon Senior Software Developer 15 years of PHP experience

    10 years of Symfony experience Conferences speaker @hhamon on social networks Books (co) author
  3. In software design, a design pattern is an abstract generic

    solution to solve a particular redundant problem.
  4. Creational Abstract Factory Builder Factory Method Prototype Singleton Creational design

    patterns are responsible for encapsulating the algorithms for producing and assembling objects. Patterns
  5. Structural Adapter Bridge Composite Decorator Facade Flyweight Proxy Structural design

    patterns organize classes in a way to separate their implementations from their interfaces. Patterns
  6. Behavioral Chain of Responsibility Command Interpreter Iterator Mediator Memento Observer

    State Strategy Template Method Visitor Behavioral design patterns organize objects to make them collaborate together while reducing their coupling. Patterns
  7.   Communication   Code Testability Maintainability Loose Coupling   …

      Hard to Teach   Hard to Learn   Hard to Apply   Entry Barrier   …
  8. Composite Composite pattern enables to treat individual objects and groups

    of objects of the same type the same way with a uniform common interface.
  9. Tailored for hierarchical data structures Family Tree File Explorer Organization

    Chart Content Management Book XML Parsing Navigation Bars etc. Web Forms Products Combos
  10. namespace Routing; use Routing\Exception\RouteNotFoundException; final class UrlMatcher implements UrlMatcherInterface {

    private $routes; public function __construct(RouteCollection $routes) { $this->routes = $this->sortByPath($routes); } public function match(Request $request): array { if (!isset($this->routes[$path = $request->getPathInfo()])) { throw RouteNotFoundException::forUnsupportedPath($path); } return $this->routes[$path]; } }
  11. # config/routes.php use Routing\Route; use Routing\RouteCollection; $routes = new RouteCollection();

    $route1 = new Route('/contact', 'App\\Action\\ContactDisplay'); $route1->setMethods(['GET']); $routes->add('get_contact', $route1); $route2 = new Route('/contact', 'App\\Action\\ContactProcess'); $route2->setMethods(['POST']); $routes->add('post_contact', $route2); return $routes;
  12. use Routing\UrlMatcher; use Routing\Loader\PhpFileLoader; $loader = new PhpFileLoader(); $routes =

    $loader->load('config/routes.php'); $router = new UrlMatcher($routes); $infos = $router->match(new Request('GET', '/home'));
  13. namespace Routing\Loader; use Routing\Exception\UnsupportedFileException; use Routing\RouteCollection; class PhpFileLoader implements FileLoaderInterface

    { public function load(string $path): RouteCollection { if (!file_exists($path)) { throw UnsupportedFileException::forUnreadableFile($path); } if ('php' !== pathinfo($path, PATHINFO_EXTENSION)) { throw UnsupportedFileException::forUnsupportedType($path, 'php'); } $routes = require $path; if (!$routes instanceof RouteCollection) { throw UnsupportedFileException::forUnexpectedData($path, $routes); } return $routes; } }
  14. <!–- config/routes.xml -> <?xml version="1.0" encoding="UTF-8"?> <routes> <route name="get_contact" path="/contact"

    methods="GET" controller="App\Action\ContactDisplay"/> <!-- ... -> </routes>
  15. class XmlFileLoader implements FileLoaderInterface { public function load(string $path): RouteCollection

    { // ... try { $xml = new \SimpleXMLElement(file_get_contents($path)); } catch (\Exception $e) { throw UnsupportedFileException::forUnreadableFile($path, $e); } $routes = new RouteCollection(); foreach ($xml->route as $route) { $routes->add($route['name'], $this->makeRoute($route)); } return $routes; } }
  16. use Routing\UrlMatcher; use Routing\Loader\XmlFileLoader; $loader = new XmlFileLoader(); $routes =

    $loader->load('config/routes.xml'); $router = new UrlMatcher($routes); $infos = $router->match(new Request('GET', '/home'));
  17. use Routing\Loader\DelegatingLoader; use Routing\Loader\PhpFileLoader; use Routing\Loader\XmlFileLoader; $loader = new DelegatingLoader();

    $loader->add(new PhpFileLoader()); $loader->add(new XmlFileLoader()); $routes = $loader->load('config/routes.xml'); $routes = $loader->load('config/routes.php');
  18. class DelegatingLoader implements FileLoaderInterface { private $loaders = []; public

    function add(FileLoaderInterface $loader): void { $this->loaders[] = $loader; } // ... }
  19. class DelegatingLoader implements FileLoaderInterface { // ... public function load(string

    $path): RouteCollection { foreach ($this->loaders as $loader) { if ($routes = $this->tryLoader($path, $loader)) { return $routes; } } throw UnsupportedFileException::forUnsupportedType($path); } private function tryLoader(string $path, FileLoaderInterface $loader): ?RouteCollection { try { return $loader->load($path); } catch (UnsupportedFileException $exception) { return null; } } }
  20. Builder Builder pattern separates the construction of a complex object

    from its representation so that the same construction process can create different representations.
  21. final class UrlMatcherBuilder implements UrlMatcherBuilderInterface { private $loader; private $routes;

    public function __construct(FileLoaderInterface $loader) { $this->loader = $loader; $this->routes = new RouteCollection(); } public static function create(): self { $loader = new DelegatingLoader(); $loader->add(new XmlFileLoader()); $loader->add(new PhpFileLoader()); return new self($loader); } }
  22. class UrlMatcherBuilder { public function load(string $path): self { $this->routes->merge($this->loader->load($path));

    return $this; } public function add(string $path, string $name, callable $callback): self { $this->routes->add(new Route($path, $name, $callback)); return $this; } public function build(): UrlMatcherInterface { return new UrlMatcher($this->routes); } }
  23. // ... use Psr\Log\LoggerInterface; class LoggableUrlMatcher implements UrlMatcherInterface { private

    $urlMatcher; private $logger; public function __construct( UrlMatcherInterface $urlMatcher, LoggerInterface $logger ) { $this->urlMatcher = $urlMatcher; $this->logger = $logger; } }
  24. class LoggableUrlMatcher implements UrlMatcherInterface { // ... public function match(Request

    $request): array { $this->logger->info(sprintf('Matching path "%s".', $request->getPathInfo())); try { $infos = $this->urlMatcher->match($request); $this->logger->info(sprintf('Matched route "%s".', $infos['_route'])); } catch (RoutingException $e) { $this->logger->info($e->getMessage()); throw $e; } return $infos; } }
  25. $loader = new PhpFileLoader(); $routes = $loader->load('/config/routes.php'); $matcher = new

    LoggableUrlMatcher( new UrlMatcher($routes), new Logger('/tmp/app.log') ); $matcher->match(new Request(...));
  26. Coupon Codes Management Consider an e-commerce website offering coupon codes

    for customers to reduce their orders. Coupons are accepted and will discount an order total amount if their restrictions constraints are validated.
  27. Coupons Restrictions ü  Limited lifetime period, ü  Minimum total amount

    required, ü  Minimum quantity of ordered items required, ü  Valid for a specific geographical area, ü  Customer must own the loyalty membership card, ü  Coupon is valid for Premium / VIP customers only, ü  Valid for the customer’s very first order, ü  Some products are not eligible for discounts, ü  Valid only on some specific products areas (luxury, food…), ü  etc.
  28. interface CouponInterface { public function getCode(): string; /** * Returns

    the new total amount after the coupon has been * applied on the given order. * * @param OrderableInterface $order The order to discount * * @return Money The new order total amount * * @throws CouponException When coupon is not applicable */ public function applyDiscount(OrderableInterface $order): Money; }
  29. class ValueCoupon implements CouponInterface { private $code; private $discount; public

    function __construct(string $code, Money $discount) { $this->code = $code; $this->discount = $discount; } public function getCode(): string { return $this->code; } public function applyDiscount(OrderableInterface $order): Money { return $order->getTotalAmount()->subtract($this->discount); } } Modelling a Value Coupon
  30. class RateCoupon implements CouponInterface { private $code; private $rate; public

    function __construct(string $code, float $rate) { Assertion::greaterThan($rate, 0); Assertion::lowerThanOrEqual($rate, 1); $this->code = $code; $this->rate = $rate; } } Modelling a Rate Coupon
  31. class RateCoupon implements CouponInterface { // ... public function getCode()

    { return $this->code; } public function applyDiscount(OrderableInterface $order): Money { $amount = $order->getTotalAmount(); return $amount->subtract($amount->multiply($this->rate)); } }
  32. abstract class CouponDecorator implements CouponInterface { protected $coupon; public function

    __construct(CouponInterface $coupon) { $this->coupon = $coupon; } public function getCode(): string { return $this->coupon->getCode(); } protected function createCouponException(string $message, \Throwable $previous = null) { return new CouponException($message, 0, $previous); } } Modelling a Coupon Decorator
  33. class LimitedLifetimeCoupon extends CouponDecorator { private $period; function __construct(CouponInterface $coupon,

    DateTimePeriod $period) { parent::__construct($coupon); $this->period = $period; } public function applyDiscount(OrderableInterface $order): Money { // ... Add restriction logic here. return $this->coupon->applyDiscount($order); } }
  34. class LimitedLifetimeCoupon extends CouponDecorator { // ... public function applyDiscount(OrderableInterface

    $order): Money { if (!$this->period->isStarted()) { throw $this->createCouponException(sprintf( 'Coupon is usable from %s.', $this->period->getStartDate() )); } if ($this->period->isFinished()) { throw $this->createCouponException(sprintf( 'Coupon was valid until %s.', $this->period->getDueDate() )); } return $this->coupon->applyDiscount($order); } }
  35. use SebastianBergmann\Money\Money; use Shop\Discount\LimitedLifetimeCoupon; use Shop\Discount\RateCoupon; use Shop\Discount\ValueCoupon; // Coupon

    offers a discount value of 20 €. $coupon1 = new LimitedLifetimeCoupon( new ValueCoupon('3s2h7pd65s', Money::fromString('20.00', EUR')), DateTimePeriod::fromString('FROM 2017-12-01 TO 2017-12-31'); ); // Coupon offers 25% off. $coupon2 = new LimitedLifetimeCoupon( new RateCoupon('76cqa6qr19', 0.25), DateTimePeriod::fromString('FROM 2017-12-01 TO 2017-12-31'); );
  36. class MinimumPurchaseAmountCoupon extends CouponDecorator { private $minimumAmount; function __construct(CouponInterface $coupon,

    Money $minAmount) { parent::__construct($coupon); $this->minimumAmount = $minAmount; } public function applyDiscount(OrderableInterface $order): Money { $amount = $order->getTotalAmount(); if ($amount->lessThan($this->minimumAmount)) { throw $this->createCouponException(...); } return $this->coupon->applyDiscount($order); } }
  37. use SebastianBergmann\Money\Money; use Shop\Discount\MinimumPurchaseAmountCoupon; use Shop\Discount\RateCoupon; use Shop\Discount\ValueCoupon; // Get

    20 € off if total amount is greater than 300 € $coupon1 = new MinimumPurchaseAmountCoupon( new ValueCoupon('3s2h7pd65s', new EUR('20.00')), new EUR('300.00') ); // Get 25% off if total amount is greater than 300 € $coupon2 = new MinimumPurchaseAmountCoupon( new RateCoupon('76cqa6qr19', 0.25), new EUR('300.00') );
  38. // ... class CustomerFirstOrderCoupon extends CouponDecorator { public function applyDiscount(OrderableInterface

    $order) { $customer = $order->getCustomer(); if ($customer->hasPastOrders()) { throw $this->createCouponException( 'Customer already has past orders.’ ); } return $this->coupon->applyDiscount($order); } }
  39. use SebastianBergmann\Money\Money; use Shop\Discount\CustomerFirstOrderCoupon; use Shop\Discount\RateCoupon; use Shop\Discount\ValueCoupon; // Get

    20 € off on the first order $coupon1 = new CustomerFirstOrderCoupon( new ValueCoupon( '3s2h7pd65s', new EUR('20.00') ) ); // Get 25% off on the first order $coupon2 = new CustomerFirstOrderCoupon( new RateCoupon('76cqa6qr19', 0.25) );
  40. // Create the special coupon $coupon = new CustomerFirstOrderCoupon( new

    MinimumPurchaseAmountCoupon( new LimitedLifetimeCoupon( new ValueCoupon('3s2h7pd65s', new EUR('20.00')), DateTimePeriod::until('+60 days') ), new EUR('170.00') ) ); // Create the order instance $customer = new \Shop\Customer('[email protected]'); $order = new \Shop\Order($customer, new EUR('200.00')); // Apply discount coupon on the order $discountedAmount = $coupon->applyDiscount($order);
  41. Easy to setup Infinite combinations Unchanged existing code Loose coupling

      SOLID compliant   …   Large objects graphs   Proxy objects   Fluent interfaces   …
  42. Abstract Factory Abstract Factory provides an interface for creating families

    of related or dependent objects without specifying their concrete classes.
  43. Both assessments have similarities -­‐ Symfony  2.3  /  3.0 Twig

    Pricing €250 (USA, Europe, UAE, Japan, etc.) €200 (Brazil, Tunisia, India, etc.) €149 (no country restriction) Eligibility Conditions Candidate must be at least 18 y.o Candidate must not be Expert Certified Up to 2 sessions max per civil year No active exam registration 1 week blank period between 2 exams Candidate must be at least 18 y.o Candidate must not be Expert Certified No active exam registration 1 month blank period between 2 exams Levels Expert Symfony Developer Advanced Symfony Developer Expert Twig Web Designer
  44. class PlaceOrderCommandHandler { private $symfonyPricer; private $twigPricer; // ... public

    function handle(PlaceOrderCommand $command): void { $order = new Order(); $order->setQuantity($command->getQuantity()); $order->setUnitPrice($this->getUnitPrice($command)); // ... } }
  45. class PlaceOrderCommandHandler { // ... private function getUnitPrice(PlaceOrderCommand $command): Money

    { $country = $command->getCountry(); switch ($code = $command->getExamSeriesType()) { case 'TWXCE': return $this->twigPricer->getUnitPrice($country); case 'SFXCE': return $this->symfonyPricer->getUnitPrice($country); } throw new UnsupportedAssessmentException($code); } }
  46. namespace Certification; interface CertificationFactoryInterface { public function createEligibilityChecker() : CertificationEligibilityCheckerInterface;

    public function createTicketPricer() : CertificationTicketPricerInterface; public function createAuthority() : CertificationAuthorityInterface; }
  47. namespace Certification; use Certification\Domain\AssessmentResult; use Certification\Exception\CandidateNotCertifiedException; use Certification\Exception\UnsupportedAssessmentException; interface CertificationAuthorityInterface

    { /** * Returns the candidate's certification level. * * @throws CandidateNotCertifiedException * @throws UnsupportedAssessmentException */ public function getCandidateLevel(AssessmentResult $result): string; }
  48. $pricer = new \Certification\Symfony\TicketPricer( Money::fromString('250', 'EUR'), Money::fromString('200', 'EUR') ); $price

    = $pricer->getUnitPrice('FR'); // €250 $price = $pricer->getUnitPrice('TN'); // €200 $pricer = new \Certification\Twig\TicketPricer( Money::fromString('149', 'EUR') ); $price = $pricer->getUnitPrice('FR'); // €149 $price = $pricer->getUnitPrice('TN'); // €149
  49. namespace Certification; use SebastianBergmann\Money\Money; abstract class AbstractTicketPricer implements CertificationTicketPricerInterface {

    /** * Returns the total price for the given quantity. */ public function getTotalPrice(string $country, int $quantity): Money { if ($quantity < 1) { throw new \InvalidTicketQuantityException($quantity); } return $this->getUnitPrice($country)->multiply($quantity); } }
  50. namespace Certification\Twig; use SebastianBergmann\Money\Money; use Certification\AbstractTicketPricer; class TicketPricer extends AbstractTicketPricer

    { private $price; public function __construct(Money $price) { $this->price = $price; } public function getUnitPrice(string $country): Money { return $this->price; } }
  51. namespace Certification\Symfony; use SebastianBergmann\Money\Money; use Certification\AbstractTicketPricer; class TicketPricer extends AbstractTicketPricer

    { private $regularPrice; private $discountPrice; public function __construct(Money $regularPrice, Money $discountPrice) { $this->regularPrice = $regularPrice; $this->discountPrice = $discountPrice; } public function getUnitPrice(string $country): Money { if (in_array($country, ['FR', 'US', 'DE', 'IT', 'CH', 'BE', /* … */])) { return $this->regularPrice; } return $this->discountPrice; } }
  52. try { $assessment = AssessmentResult::fromCSV('SL038744,SF3CE,35'); $authority = new \Certification\Symfony\Authority(20, 10);

    $symfonyLevel = $authority->getCandidateLevel($assessment); $assessment = AssessmentResult::fromCSV('SL038744,TW1CE,17'); $authority = new \Certification\Twig\Authority(15); $twigLevel = $authority->getCandidateLevel($assessment); } catch (CandidateNotCertifiedException $e) { // ... } catch (UnsupportedAssessmentException $e) { // ... } catch (\Exception $e) { // ... }
  53. namespace Certification\Twig; use Certification\CertificationAuthorityInterface; use Certification\Domain\AssessmentResult; use Certification\Exception\CandidateNotCertifiedException; use Certification\Exception\UnsupportedAssessmentException;

    class Authority implements CertificationAuthorityInterface { // ... public function getCandidateLevel(AssessmentResult $result): string { if ('TW1CE' !== $assessmentID = $result->getAssessmentID()) { throw new UnsupportedAssessmentException($assessmentID); } if ($result->getScore() < $this->passingScore) { throw new CandidateNotCertifiedException($result->getCandidateID(), $assessmentID); } return 'expert'; } }
  54. namespace Certification\Symfony; use Certification\CertificationAuthorityInterface; use Certification\Entity\AssessmentResult; use Certification\Exception\CandidateNotCertifiedException; use Certification\Exception\UnsupportedAssessmentException;

    class Authority implements CertificationAuthorityInterface { private $expertLevelPassingScore; private $advancedLevelPassingScore; public function __construct(int $expertScore, int $advancedScore) { $this->expertLevelPassingScore = $expertScore; $this->advancedLevelPassingScore = $advancedScore; } // ... }
  55. class Authority implements CertificationAuthorityInterface { public function getCandidateLevel(AssessmentResult $result): string

    { $assessmentID = $result->getAssessmentID(); if (!in_array($assessmentID, ['SF2CE', 'SF3CE'])) { throw new UnsupportedAssessmentException($assessmentID); } $score = $result->getScore(); if ($score >= $this->expertLevelPassingScore) { return 'expert'; } if ($score >= $this->advancedLevelPassingScore) { return 'advanced'; } throw new CandidateNotCertifiedException($result->getCandidateID(), $assessmentID); } // ... }
  56. namespace Certification\Symfony; use SebastianBergmann\Money\Money; use Certification\CertificationAuthorityInterface; use Certification\CertificationFactoryInterface; // ...

    class CertificationFactory implements CertificationFactoryInterface { // ... public function createTicketPricer(): CertificationTicketPricerInterface { return new TicketPricer( Money::fromString('250', 'EUR'), Money::fromString('200', 'EUR') ); } public function createAuthority(): CertificationAuthorityInterface { return new Authority(20, 10); } }
  57. namespace Certification\Twig; use SebastianBergmann\Money\Money; use Certification\CertificationAuthorityInterface; use Certification\CertificationFactoryInterface; // ...

    class CertificationFactory implements CertificationFactoryInterface { // ... public function createTicketPricer(): CertificationTicketPricerInterface { return new TicketPricer(Money::fromString('149', 'EUR')); } public function createAuthority(): CertificationAuthorityInterface { return new Authority(20); } }
  58. $assessmentFamily = 'SFXCE'; // Choose the right factory switch ($assessmentFamily)

    { case 'TWXCE': $factory = new TwigCertificationFactory(...); break; case 'SFXCE': $factory = new SymfonyCertificationFactory(...); break; default: throw new UnsupportedAssessmentFamilyException($assessmentFamily); } // Request and use the factory services $pricer = $factory->createTicketPricer(); $checker = $factory->createEligibilityChecker(); $authority = $factory->createAuthority();
  59. namespace Certification; use Certification\Exception\UnsupportedAssessmentException; //... class CertificationFactoryFactory implements CertificationFactoryFactoryInterface {

    private $factories = []; public function getFactory(string $assessment): CertificationFactoryInterface { if (isset($this->factories[$assessment])) { return $this->factories[$assessment]; } if ('TW1CE' === $assessment) { return $this->createTwigCertificationFactory($assessment); } if (in_array($assessment, ['SF2CE', 'SF3CE'], true)) { return $this->createSymfonyCertificationFactory($assessment); } throw new UnsupportedAssessmentException($assessment); } }
  60. // ... use Certification\Symfony\CertificationFactory as SymfonyCertificationFactory; use Certification\Twig\CertificationFactory as TwigCertificationFactory;

    class CertificationFactoryFactory implements CertificationFactoryFactoryInterface { private $factories = []; public function getFactory(string $assessment): CertificationFactoryInterface { if (isset($this->factories[$assessment])) { return $this->factories[$assessment]; } if ('TW1CE' === $assessment) { return $this->createTwigCertificationFactory($assessment); } if (in_array($assessment, ['SF2CE', 'SF3CE'], true)) { return $this->createSymfonyCertificationFactory($assessment); } throw new UnsupportedAssessmentException($assessment); } }
  61. class CertificationFactoryFactory implements CertificationFactoryFactoryInterface { // ... private function createTwigCertificationFactory(string

    $assessment) : CertificationFactoryInterface { $this->factories[$assessment] = $f = new TwigCertificationFactory(...); return $f; } private function createSymfonyCertificationFactory(string $assessment) : CertificationFactoryInterface { $this->factories[$assessment] = $f = new SymfonyCertificationFactory(...); return $f; } }
  62. class CertificationFactoryFactory implements CertificationFactoryFactoryInterface { // ... public function createCertificationTicketPricer(string

    $assessment) : CertificationTicketPricerInterface { return $this->getFactory($assessment)->createTicketPricer(); } public function createCertificationAuthority(string $assessment) : CertificationAuthorityInterface { return $this->getFactory($assessment)->createCertificationAuthority(); } public function createCertificationEligibilityChecker(string $assessment) : CertificationEligibilityCheckerInterface { return $this->getFactory($assessment)->createEligibilityChecker(); } }