Using the Workflow component for e-commerce

Using the Workflow component for e-commerce

We got the task to make an order API, from open order, to delivered, with payments in between and after. So there are naturally a lot of states, and a lot of transitions where we needed to calculate the prices correctly and handle credit card transfers. Keeping track of all of this, and when we need to do what, ensuring that an order is always up to date, and that it has the data it needs, and that we send good error messages when a user can not do an action, was a challenge for us until we discovered the workflow component. This is a real happy use case story where I will show you how we did this, and how much more straightforward it was for us to build an otherwise complex system using the workflow component.

01da6d807a29ad6d49801c0157518148?s=128

Michelle Sanver

May 10, 2019
Tweet

Transcript

  1. @michellesanver #phpday Using the Workflow component for e-commerce
 #phpday |

    10th of May 2019
  2. @michellesanver #phpday “Learn the most by sharing your knowledge with

    others” - @coderabbi WIIIIE \o/
  3. @michellesanver #phpday Using the Workflow component for e-commerce

  4. @michellesanver #phpday Writing Good Code

  5. @michellesanver #phpday This talk assumes little knowledge

  6. @michellesanver #phpday This talk is… Open Source

  7. None
  8. @michellesanver #phpday Michelle Sanver Colour and code addict

  9. @michellesanver #phpday Accent!?

  10. @michellesanver #phpday

  11. @michellesanver #phpday “It’s like Uber… 
 For shopping”

  12. @michellesanver #phpday Order API Challenges

  13. @michellesanver #phpday Keeping orders/prices up to date

  14. @michellesanver #phpday Making our consumers love us
 (Good error messages

    and documentation)
  15. @michellesanver #phpday Handling cancellation of an order

  16. @michellesanver #phpday Handling a large amount of SMS and Push

    notifications
  17. @michellesanver #phpday

  18. @michellesanver #phpday State Machines

  19. @michellesanver #phpday Why?

  20. @michellesanver #phpday GREEN YELLOW RED

  21. @michellesanver #phpday GREEN YELLOW RED

  22. @michellesanver #phpday GREEN YELLOW RED

  23. @michellesanver #phpday GREEN YELLOW RED

  24. @michellesanver #phpday GREEN YELLOW RED Timer

  25. @michellesanver #phpday GREEN YELLOW RED Timer T1

  26. @michellesanver #phpday GREEN YELLOW RED Timer T1 T1

  27. @michellesanver #phpday GREEN YELLOW RED Timer T1 T1 T1

  28. @michellesanver #phpday GREEN YELLOW RED Timer T1 T1 T1 T0

  29. @michellesanver #phpday GREEN YELLOW RED Timer T1 T1 T1 T0

    T0 T0
  30. @michellesanver #phpday GREEN RED ;) BLUE

  31. @michellesanver #phpday GREEN YELLOW BLUE T1 T2 Workflow

  32. @michellesanver #phpday GREEN YELLOW BLUE T1 T2 State Machine T3

  33. @michellesanver #phpday We already think in states and workflows

  34. @michellesanver #phpday The Order
 State Machine

  35. @michellesanver #phpday Happy Workflow

  36. @michellesanver #phpday 1. An order is created Open

  37. @michellesanver #phpday 2. Items are updated Open

  38. @michellesanver #phpday 3. User sets delivery address on order. Open

  39. @michellesanver #phpday 4. User checks out Confirmed

  40. @michellesanver #phpday 5. Pickers get notified Confirmed

  41. @michellesanver #phpday 6. Picker accepts order Assigned To Picker

  42. @michellesanver #phpday 7. Picker goes shopping Picked Up

  43. @michellesanver #phpday 8. Picker delivers order Delivered

  44. @michellesanver #phpday 9. Card gets charged for order, picker gets

    paid Delivered
  45. @michellesanver #phpday Open Confirmed Assigned To Picker Picked Up Delivered

    Happy Workflow
  46. @michellesanver #phpday Cancelled Aborted

  47. @michellesanver #phpday Cancelled By System

  48. @michellesanver #phpday Open Confirmed Assigned To Picker Picked Up Delivered

    Cancelled Aborted Cancelled By System
  49. @michellesanver #phpday Open Confirmed Assigned To Picker Picked Up Delivered

    Cancelled Aborted Cancelled By System
  50. @michellesanver #phpday Open Confirmed Assigned To Picker Picked Up Delivered

    Cancelled Aborted Cancelled By System Cancel Order For technical Reasons Cancel Abort Checkout Assign to picker Pick Up Deliver Cancel Cancel Cancel
  51. @michellesanver #phpday Open Open UpdateItem

  52. @michellesanver #phpday State machines make business logic explicit

  53. @michellesanver #phpday Open Confirmed Assigned To Picker Picked Up Delivered

    Cancelled Aborted Cancelled By System Cancel Order For technical Reasons Cancel Abort Checkout Assign to picker Pick Up Deliver Cancel Cancel Cancel
  54. @michellesanver #phpday A workflow does not contain all logic

  55. @michellesanver #phpday The Workflow Component

  56. @michellesanver #phpday composer require symfony/workflow

  57. @michellesanver #phpday Our Workflow

  58. @michellesanver #phpday Open Confirmed Assigned To Picker Picked Up Delivered

    Cancelled Aborted Cancelled By System Cancel Order For technical Reasons Cancel Abort Checkout Assign to picker Pick Up Deliver Cancel Cancel Cancel
  59. @michellesanver #phpday framework: workflows: order: type: state_machine marking_store: type: 'single_state'

    arguments: - 'state' supports: - AppBundle\Entity\Order places: - open - confirmed - assigned-to-delivery-person - picked-up - delivered - aborted - cancelled transitions: updateItem: from: open to: open
  60. @michellesanver #phpday framework: workflows: order: type: state_machine marking_store: type: 'single_state'

    arguments: - 'state' supports: - AppBundle\Entity\Order
  61. @michellesanver #phpday framework: workflows: order: type: state_machine marking_store: type: 'single_state'

    arguments: - 'state' supports: - AppBundle\Entity\Order
  62. @michellesanver #phpday framework: workflows: order: type: state_machine marking_store: type: 'single_state'

    arguments: - 'state' supports: - AppBundle\Entity\Order
  63. @michellesanver #phpday framework: workflows: order: type: state_machine marking_store: type: 'single_state'

    arguments: - 'state' supports: - AppBundle\Entity\Order
  64. @michellesanver #phpday framework: workflows: order: type: state_machine marking_store: type: 'single_state'

    arguments: - 'state' supports: - AppBundle\Entity\Order
  65. @michellesanver #phpday places: - open - confirmed - assigned-to-picker -

    picked-up - delivered - aborted - cancelled - cancelled-by-system
  66. @michellesanver #phpday transitions: updateItem: from: open to: open

  67. @michellesanver #phpday The Model

  68. @michellesanver #phpday public const STATE_OPEN = 'open'; public const STATE_CONFIRMED

    = 'confirmed'; public const STATE_ASSIGNED_TO_PICKER = 'assigned-to-picker'; public const STATE_PICKED_UP = 'picked-up'; public const STATE_DELIVERED = 'delivered'; public const STATE_ABORTED_BY_CUSTOMER = 'aborted'; public const STATE_CANCELLED_BY_SUPPORT = 'cancelled'; public const STATE_CANCELLED_BY_SYSTEM = 'cancelled-by-system';
  69. @michellesanver #phpday public const STATES = [ self::STATE_OPEN, self::STATE_CONFIRMED, self::STATE_ASSIGNED_TO_PICKER,

    self::STATE_PICKED_UP, self::STATE_DELIVERED, self::STATE_ABORTED_BY_CUSTOMER, self::STATE_CANCELLED_BY_SUPPORT, self::STATE_CANCELLED_BY_SYSTEM, ];
  70. @michellesanver #phpday public const TRANSITION_UPDATE_ITEM = 'updateItem';

  71. @michellesanver #phpday /** * @var string */ private $state =

    self::STATE_OPEN;
  72. @michellesanver #phpday

  73. @michellesanver #phpday Events

  74. @michellesanver #phpday Solves Tight Coupling

  75. @michellesanver #phpday EventDispatcher

  76. @michellesanver #phpday composer require symfony/event-dispatcher

  77. @michellesanver #phpday The Listener

  78. @michellesanver #phpday class OrderWorkflowListener implements EventSubscriberInterface {
 … }

  79. @michellesanver #phpday /** * @return array Hashmap of Symfony events

    this handler cares about */ public static function getSubscribedEvents() { return [ // Guard events (Validate whether the transition is allowed at all) 'workflow.order.guard' => 'guardGeneralOrderWorkflow', sprintf('workflow.order.guard.%s', Order::TRANSITION_CHANGE_REGION) => 'guardChangeRegion', sprintf('workflow.order.guard.%s', Order::TRANSITION_CONFIRM_ORDER) => 'guardConfirmOrder', sprintf('workflow.order.guard.%s', Order::TRANSITION_ASSIGN_PICKER) => 'guardAssignPicker', sprintf('workflow.order.guard.%s', Order::TRANSITION_ASSIGN_CUSTOMER) => 'guardAssignCustomer', // Enter events (The object is about to enter a new place) sprintf('workflow.order.enter.%s', Order::STATE_CONFIRMED) => 'enterConfirmed', sprintf('workflow.order.enter.%s', Order::STATE_DELIVERED) => 'enterDelivered', // Entered events (The object entered a new place) sprintf('workflow.order.entered.%s', Order::STATE_PICKED_UP) => 'enteredPickedUp', // The object has completed this transition. 'workflow.order.completed' => 'onCompleted', ]; }
  80. @michellesanver #phpday /** * @return array Hashmap of Symfony events

    this handler cares about */ public static function getSubscribedEvents() { return [ // Guard events (Validate whether the transition is allowed at all) 'workflow.order.guard' => 'guardGeneralOrderWorkflow', sprintf('workflow.order.guard.%s', Order::TRANSITION_CHANGE_REGION) => 'guardChangeRegion', sprintf('workflow.order.guard.%s', Order::TRANSITION_CONFIRM_ORDER) => 'guardConfirmOrder', sprintf('workflow.order.guard.%s', Order::TRANSITION_ASSIGN_PICKER) => 'guardAssignPicker', sprintf('workflow.order.guard.%s', Order::TRANSITION_ASSIGN_CUSTOMER) => 'guardAssignCustomer', // Enter events (The object is about to enter a new place) sprintf('workflow.order.enter.%s', Order::STATE_CONFIRMED) => 'enterConfirmed', sprintf('workflow.order.enter.%s', Order::STATE_DELIVERED) => 'enterDelivered', // Entered events (The object entered a new place) sprintf('workflow.order.entered.%s', Order::STATE_PICKED_UP) => 'enteredPickedUp', // The object has completed this transition. 'workflow.order.completed' => 'onCompleted', ]; }
  81. @michellesanver #phpday /** * @return array Hashmap of Symfony events

    this handler cares about */ public static function getSubscribedEvents() { return [ // Guard events (Validate whether the transition is allowed at all) 'workflow.order.guard' => 'guardGeneralOrderWorkflow', sprintf('workflow.order.guard.%s', Order::TRANSITION_CHANGE_REGION) => 'guardChangeRegion', sprintf('workflow.order.guard.%s', Order::TRANSITION_CONFIRM_ORDER) => 'guardConfirmOrder', sprintf('workflow.order.guard.%s', Order::TRANSITION_ASSIGN_PICKER) => 'guardAssignPicker', sprintf('workflow.order.guard.%s', Order::TRANSITION_ASSIGN_CUSTOMER) => 'guardAssignCustomer', // Enter events (The object is about to enter a new place) sprintf('workflow.order.enter.%s', Order::STATE_CONFIRMED) => 'enterConfirmed', sprintf('workflow.order.enter.%s', Order::STATE_DELIVERED) => 'enterDelivered', // Entered events (The object entered a new place) sprintf('workflow.order.entered.%s', Order::STATE_PICKED_UP) => 'enteredPickedUp', // The object has completed this transition. 'workflow.order.completed' => 'onCompleted', ]; }
  82. @michellesanver #phpday /** * @return array Hashmap of Symfony events

    this handler cares about */ public static function getSubscribedEvents() { return [ // Guard events (Validate whether the transition is allowed at all) 'workflow.order.guard' => 'guardGeneralOrderWorkflow', sprintf('workflow.order.guard.%s', Order::TRANSITION_CHANGE_REGION) => 'guardChangeRegion', sprintf('workflow.order.guard.%s', Order::TRANSITION_CONFIRM_ORDER) => 'guardConfirmOrder', sprintf('workflow.order.guard.%s', Order::TRANSITION_ASSIGN_PICKER) => 'guardAssignPicker', sprintf('workflow.order.guard.%s', Order::TRANSITION_ASSIGN_CUSTOMER) => 'guardAssignCustomer', // Enter events (The object is about to enter a new place) sprintf('workflow.order.enter.%s', Order::STATE_CONFIRMED) => 'enterConfirmed', sprintf('workflow.order.enter.%s', Order::STATE_DELIVERED) => 'enterDelivered', // Entered events (The object entered a new place) sprintf('workflow.order.entered.%s', Order::STATE_PICKED_UP) => 'enteredPickedUp', // The object has completed this transition. 'workflow.order.completed' => 'onCompleted', ]; }
  83. @michellesanver #phpday /** * @return array Hashmap of Symfony events

    this handler cares about */ public static function getSubscribedEvents() { return [ // Guard events (Validate whether the transition is allowed at all) 'workflow.order.guard' => 'guardGeneralOrderWorkflow', sprintf('workflow.order.guard.%s', Order::TRANSITION_CHANGE_REGION) => 'guardChangeRegion', sprintf('workflow.order.guard.%s', Order::TRANSITION_CONFIRM_ORDER) => 'guardConfirmOrder', sprintf('workflow.order.guard.%s', Order::TRANSITION_ASSIGN_PICKER) => 'guardAssignPicker', sprintf('workflow.order.guard.%s', Order::TRANSITION_ASSIGN_CUSTOMER) => 'guardAssignCustomer', // Enter events (The object is about to enter a new place) sprintf('workflow.order.enter.%s', Order::STATE_CONFIRMED) => 'enterConfirmed', sprintf('workflow.order.enter.%s', Order::STATE_DELIVERED) => 'enterDelivered', // Entered events (The object entered a new place) sprintf('workflow.order.entered.%s', Order::STATE_PICKED_UP) => 'enteredPickedUp', // The object has completed this transition. 'workflow.order.completed' => 'onCompleted', ]; }
  84. @michellesanver #phpday Workflow component does so much for us

  85. @michellesanver #phpday Order API Challenges Solved

  86. @michellesanver #phpday Keeping orders/prices up to date

  87. @michellesanver #phpday public function onCompleted(Event $event): void { $transition =

    $event->getTransition(); $fromState = $transition->getFroms()[0]; /** @var Order $order */ $order = $event->getSubject(); // If an order is already in delivered state, all calculations are done, and should stay as they are. if (Order::STATE_DELIVERED !== $fromState) { $this->orderModifier->updateTotals($order); } $this->orderStateHistoryRepository->storeOrderStateTransition($order, $transition); $this->orderEventService->eventHappened($transition->getName(), $order->getId(), $fromState); }
  88. @michellesanver #phpday public function onCompleted(Event $event): void { $transition =

    $event->getTransition(); $fromState = $transition->getFroms()[0]; /** @var Order $order */ $order = $event->getSubject(); // If an order is already in delivered state, all calculations are done, and should stay as they are. if (Order::STATE_DELIVERED !== $fromState) { $this->orderModifier->updateTotals($order); } $this->orderStateHistoryRepository->storeOrderStateTransition($order, $transition); $this->orderEventService->eventHappened($transition->getName(), $order->getId(), $fromState); }
  89. @michellesanver #phpday if (Order::STATE_DELIVERED !== $fromState) { $this->orderModifier->updateTotals($order); }

  90. @michellesanver #phpday Making our consumers love us
 (Good error messages

    and documentation)
  91. @michellesanver #phpday Handling cancellation of an order

  92. @michellesanver #phpday Handling a large amount of SMS and Push

    notifications
  93. @michellesanver #phpday public function onCompleted(Event $event): void { $transition =

    $event->getTransition(); $fromState = $transition->getFroms()[0]; /** @var Order $order */ $order = $event->getSubject(); // If an order is already in delivered state, all calculations are done, and should stay as they are. if (Order::STATE_DELIVERED !== $fromState) { $this->orderModifier->updateTotals($order); } $this->orderStateHistoryRepository->storeOrderStateTransition($order, $transition); $this->orderEventService->eventHappened($transition->getName(), $order->getId(), $fromState); }
  94. @michellesanver #phpday public function onCompleted(Event $event): void { $transition =

    $event->getTransition(); $fromState = $transition->getFroms()[0]; /** @var Order $order */ $order = $event->getSubject(); // If an order is already in delivered state, all calculations are done, and should stay as they are. if (Order::STATE_DELIVERED !== $fromState) { $this->orderModifier->updateTotals($order); } $this->orderStateHistoryRepository->storeOrderStateTransition($order, $transition); $this->orderEventService->eventHappened($transition->getName(), $order->getId(), $fromState); }
  95. @michellesanver #phpday $this->orderEventService->eventHappened($transition->getName(), $order->getId(), $fromState);

  96. @michellesanver #phpday public function eventHappened(string $type, string $orderId, ?string $previousState)

    { foreach ($this->handlers as $handler) { if ($handler->canHandle($type)) { $this->orderEventProducer->publish( $type, $orderId, $previousState, $handler->getPriority() ); return; } } }
  97. @michellesanver #phpday public function eventHappened(string $type, string $orderId, ?string $previousState)

    { foreach ($this->handlers as $handler) { if ($handler->canHandle($type)) { $this->orderEventProducer->publish( $type, $orderId, $previousState, $handler->getPriority() ); return; } } }
  98. @michellesanver #phpday class SmsNotificationHandler implements OrderEventHandler { private const TRANSITION_STATE_MAP

    = [ Order::TRANSITION_ASSIGN_PICKER => Order::STATE_ASSIGNED_TO_PICKER, Order::TRANSITION_TIMEOUT_FINDING_PICKER => Order::STATE_ABORTED_BY_CUSTOMER, Order::TRANSITION_PICKER_CONFIRMS_PURCHASE => Order::STATE_PICKED_UP, Order::TRANSITION_CONFIRM_DELIVERY => Order::STATE_DELIVERED, Order::TRANSITION_REFERRAL_PICKER_RECEIVES_REWARD => Order::STATE_DELIVERED, Order::TRANSITION_SEND_ORDER_AVAILABLE_REMINDER => Order::STATE_CONFIRMED, ];
  99. @michellesanver #phpday public function canHandle(string $type): bool { return array_key_exists($type,

    self::TRANSITION_STATE_MAP); }
  100. @michellesanver #phpday Conclusion

  101. @michellesanver #phpday We already think in states and workflows

  102. @michellesanver #phpday State machines make business logic explicit

  103. @michellesanver #phpday Workflow component does so much for us!

  104. @michellesanver #phpday Writing Good Code

  105. @michellesanver #phpday This talk is for you Questions? Contributions?

  106. @michellesanver #phpday Thank You #phpday! https://joind.in/talk/caf64