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

Unveiling the Modular Monolith — Laracon EU 2024

Unveiling the Modular Monolith — Laracon EU 2024

Mateus Guimarães

February 06, 2024
Tweet

More Decks by Mateus Guimarães

Other Decks in Programming

Transcript

  1. Mateus Guimarães Unveiling the Modular Monolith Attacking complexity in monolithic

    Laravel applications Developer and Educator @mateusjatenee Laracon EU 2024
  2. Mateus Guimarães Software Developer & Educator • Powerlifting • Playing

    guitar • Photography • Content creation • Spending time with my wife @mateusjatenee MateusGuimarães
  3. The Monolith The good parts • Everyone knows them •

    Simpler infrastructure • Simple method calls • ( Usually) one database • Database Transactions • One artifact • Easier to debug
  4. The Monolith The bad parts • Tight coupling between components

    • No well-defined boundaries • Hard to fully understand • Difficult to maintain • Hard to onboard
  5. Microservices The good parts • Easy to scale • Well-defined

    boundaries • Independent deployment • Resilience
  6. Microservices The bad parts • Multiple repositories • Complex infrastructure

    • Network calls • Multiple databases • Consistency • Harder to debug • Distributed Transactions
  7. The Modular Monolith A fair middle-ground • One artifact •

    Simple infrastructure • Database Transactions! • Easy to test • Method Calls • Logical boundaries • Low coupling between component • Independent Modules • Easier to maintain and scale
  8. What do we want in a modular application? • Allow

    modules to be developed independently • Allow modules to own all of their data • Empower teams to work in parallel • Decrease surface area and blast radius of changes
  9. readonly class User { public function __ construct( public int

    $id, public string $email ) {} } class TokenIssuer { public function issue(int $uniqueIdentifier): Token { / / some magic return new Token($payload); } } $user = new App\User('Mateus', '[email protected]'); $token = (new App\Auth\TokenIssuer) -> issue($user -> id);
  10. readonly class User { public function __ construct( public int

    $id, public string $email ) {} } class TokenIssuer { public function issue(int $uniqueIdentifier): Token { / / some magic return new Token($payload); } } $user = new App\User('Mateus', '[email protected]'); $token = (new App\Auth\TokenIssuer) -> issue($user -> id);
  11. readonly class User { public function __ construct( public int

    $id, public string $email ) {} } class TokenIssuer { public function issue(int $uniqueIdentifier): Token { / / some magic return new Token($payload); } } $user = new App\User('Mateus', '[email protected]'); $token = (new App\Auth\TokenIssuer) -> issue($user -> id);
  12. Useful Guidelines Remember: there are no hard rules • Allow

    modules to be developed independently • A module should not know about other module’s internals • Incoming messages should be translated into something meaningful to the module’s domain • The producer can have contracts redirect their implementation without the consumer ever being aware
  13. Data Transfer Objects • Bags of data • No behavior

    • Immutable • Plain Old PHP Objects • Meant to be passed around! • It’s… a contract!
  14. readonly class PendingPayment { public function __ construct( public string

    $id, public Token $token, public Cents $totalInCents, public Gateway $gateway, public string $gatewayId, ) {} }
  15. class CheckoutController { public function __ invoke(CheckoutRequest $request): JsonResponse {

    $items = $request - > items(); $total = $this - > calculateTotal($items); $order = DB::transaction(function () use ($request, $total) { $order = $request -> user() -> orders() -> make([ 'total' => $total ]); foreach ($items as $item) { $order - > addLine($item -> product, $item -> price, $item -> quantity); $item - > product - > decreaseStock($item - > quantity); } $order -> save(); $charge = $this -> paymentGateway - > charge($request -> token(), $total); $this -> createPaymentForOrder($order, $charge); $order -> prepareForShipment(); return $order; }); return new OrderResource($order); } }
  16. class CheckoutController { public function __ invoke(CheckoutRequest $request): JsonResponse {

    $items = $request - > items(); $total = $this - > calculateTotal($items); $order = DB::transaction(function () use ($request, $total) { $order = $request -> user() -> orders() -> make([ 'total' => $total ]); foreach ($items as $item) { $order - > addLine($item -> product, $item -> price, $item -> quantity); $item - > product - > decreaseStock($item - > quantity); } $order -> save(); $charge = $this -> paymentGateway - > charge($request -> token(), $total); $this -> createPaymentForOrder($order, $charge); $order -> prepareForShipment(); return $order; }); return new OrderResource($order); } }
  17. class CheckoutController { public function __ invoke(CheckoutRequest $request): JsonResponse {

    $items = $request - > items(); $total = $this - > calculateTotal($items); $order = DB::transaction(function () use ($request, $total) { $order = $request -> user() -> orders() -> make([ 'total' => $total ]); foreach ($items as $item) { $order - > addLine($item -> product, $item -> price, $item -> quantity); $item - > product - > decreaseStock($item - > quantity); } $order -> save(); $charge = $this -> paymentGateway - > charge($request -> token(), $total); $this -> createPaymentForOrder($order, $charge); $order -> prepareForShipment(); return $order; }); return new OrderResource($order); } }
  18. class CheckoutController { public function __ invoke(CheckoutRequest $request): JsonResponse {

    $items = $request - > items(); $total = $this - > calculateTotal($items); $order = DB::transaction(function () use ($request, $total) { $order = $request -> user() -> orders() -> make([ 'total' => $total ]); foreach ($items as $item) { $order - > addLine($item -> product, $item -> price, $item -> quantity); $item - > product - > decreaseStock($item - > quantity); } $order -> save(); $charge = $this -> paymentGateway - > charge($request -> token(), $total); $this -> createPaymentForOrder($order, $charge); $order -> prepareForShipment(); return $order; }); return new OrderResource($order); } }
  19. class CheckoutController { public function __ invoke(CheckoutRequest $request): JsonResponse {

    $items = $request - > items(); $total = $this - > calculateTotal($items); $order = DB::transaction(function () use ($request, $total) { $order = $request -> user() -> orders() -> make([ 'total' => $total ]); foreach ($items as $item) { $order - > addLine($item -> product, $item -> price, $item -> quantity); $item - > product - > decreaseStock($item - > quantity); } $order -> save(); $charge = $this -> paymentGateway - > charge($request -> token(), $total); $this -> createPaymentForOrder($order, $charge); $order -> prepareForShipment(); return $order; }); return new OrderResource($order); } }
  20. class CheckoutController { public function __ invoke(CheckoutRequest $request): JsonResponse {

    $items = $request - > items(); $total = $this - > calculateTotal($items); $order = DB::transaction(function () use ($request, $total) { $order = $request -> user() -> orders() -> make([ 'total' => $total ]); foreach ($items as $item) { $order - > addLine($item -> product, $item -> price, $item -> quantity); $item - > product - > decreaseStock($item - > quantity); } $order -> save(); $charge = $this -> paymentGateway - > charge($request -> token(), $total); $this -> createPaymentForOrder($order, $charge); $order -> prepareForShipment(); return $order; }); return new OrderResource($order); } }
  21. class CheckoutController { public function __ invoke(CheckoutRequest $request): JsonResponse {

    $items = $request - > items(); $total = $this - > calculateTotal($items); $order = DB::transaction(function () use ($request, $total) { $order = $request -> user() -> orders() -> make([ 'total' => $total ]); foreach ($items as $item) { $order - > addLine($item -> product, $item -> price, $item -> quantity); $item - > product - > decreaseStock($item - > quantity); } $order -> save(); $charge = $this -> paymentGateway - > charge($request -> token(), $total); $this -> createPaymentForOrder($order, $charge); $order -> prepareForShipment(); return $order; }); return new OrderResource($order); } }
  22. use Modules\Checkout\PurchaseItems; use Modules\Payment\PaymentGateway; use Modules\Product\FetchCartItemsByProductIds; class CheckoutController { public

    function __ construct( protected FetchCartItemsByProductIds $fetchCartItems, protected PurchaseItems $purchaseItems, protected PaymentGateway $paymentGateway ) {} public function __ invoke(CheckoutRequest $request): JsonResponse { $items = $this - > fetchCartItemsByProductIds -> fromCheckoutRequest($request); $this -> paymentGateway -> setToken($request -> paymentToken()); try { $order = $this - > purchaseItems -> handle($items, $this -> paymentGateway, $request - > user() -> id); } catch (ProductsOutOfStockException | PaymentFailedException) { } return new OrderResource($order); } }
  23. class PurchaseItems { public function __ construct( protected Database $database,

    ) {} public function handle(CartItemCollection $items, PaymentGateway $paymentGateway, int $userId): Order { $order = $this -> database -> transaction(function () use ($items, $paymentGateway, $userId) { $order = Order::start($userId); $order -> addLinesFromCartItems($items); $order -> save(); // So we have an ID. A UUID would suffice. foreach ($items as $item) { $item -> product -> decrement('stock', $item -> quantity); } $charge = $paymentGateway -> charge($order -> total); $payment = Payment::create([...]); $order -> complete(); Shipment::create([...]); return $order; }); return $order; } }
  24. class PurchaseItems { public function __ construct( protected Database $database,

    ) {} public function handle(CartItemCollection $items, PaymentGateway $paymentGateway, int $userId): Order { $order = $this -> database -> transaction(function () use ($items, $paymentGateway, $userId) { $order = Order::start($userId); $order -> addLinesFromCartItems($items); $order -> save(); // So we have an ID. A UUID would suffice. foreach ($items as $item) { $item -> product -> decrement('stock', $item -> quantity); } $charge = $paymentGateway -> charge($order -> total); $payment = Payment::create([...]); $order -> complete(); Shipment::create([...]); return $order; }); return $order; } }
  25. class PurchaseItems { public function __ construct( protected Database $database,

    ) {} public function handle(CartItemCollection $items, PaymentGateway $paymentGateway, int $userId): Order { $order = $this -> database -> transaction(function () use ($items, $paymentGateway, $userId) { $order = Order::start($userId); $order -> addLinesFromCartItems($items); $order -> save(); // So we have an ID. A UUID would suffice. foreach ($items as $item) { $item -> product -> decrement('stock', $item -> quantity); } $charge = $paymentGateway -> charge($order -> total); $payment = Payment::create([...]); $order -> complete(); Shipment::create([...]); return $order; }); return $order; } }
  26. class PurchaseItems { public function __ construct( protected Database $database,

    ) {} public function handle(CartItemCollection $items, PaymentGateway $paymentGateway, int $userId): Order { $order = $this -> database -> transaction(function () use ($items, $paymentGateway, $userId) { $order = Order::start($userId); $order -> addLinesFromCartItems($items); $order -> save(); // So we have an ID. A UUID would suffice. foreach ($items as $item) { $item -> product -> decrement('stock', $item -> quantity); } $charge = $paymentGateway -> charge($order -> total); $payment = Payment::create([...]); $order -> complete(); Shipment::create([...]); return $order; }); return $order; } }
  27. class PurchaseItems { public function __ construct( protected Database $database,

    ) {} public function handle(CartItemCollection $items, PaymentGateway $paymentGateway, int $userId): Order { $order = $this -> database -> transaction(function () use ($items, $paymentGateway, $userId) { $order = Order::start($userId); $order -> addLinesFromCartItems($items); $order -> save(); // So we have an ID. A UUID would suffice. foreach ($items as $item) { $item -> product -> decrement('stock', $item -> quantity); } $charge = $paymentGateway -> charge($order -> total); $payment = Payment::create([...]); $order -> complete(); Shipment::create([...]); return $order; }); return $order; } }
  28. class PurchaseItems { public function __ construct( protected Database $database,

    protected InventoryService $inventoryService, protected PaymentService $paymentService, protected ShipmentService $shipmentService, ) {} public function handle(CartItemCollection $items, PaymentGateway $paymentGateway, int $userId): OrderDto { return $this -> database -> transaction(function () use ($items, $paymentGateway, $userId) { $order = Order::start($userId); $order -> addLinesFromCartItems($items); $order -> save(); $orderDto = OrderDto::fromModel($order); foreach ($items as $item) { $this -> inventoryService -> decreaseStock($item - > productId, $item -> quantity); } $payment = $this -> paymentService -> payOrder($orderDto, $paymentGateway); $order -> complete(); $this -> shipmentService -> startShipmentForOrder($orderDto); return $orderDto; }); } }
  29. class PurchaseItems { public function __ construct( protected Bus $events,

    ) {} public function handle(CartItemCollection $items, PaymentGateway $paymentGateway, int $userId): OrderDto { $order = Order::start($userId); $order -> addLinesFromCartItems($items); $order -> initiate(); $this -> events - > dispatch( new OrderPlaced( order: $order -> toDto(), PaymentGateway: $paymentGateway, ) ); return OrderDto::fromModel($Order); } }
  30. class PurchaseItems { public function __ construct( protected Bus $events,

    ) {} public function handle(CartItemCollection $items, PaymentGateway $paymentGateway, int $userId): OrderDto { $order = Order::start($userId); $order -> addLinesFromCartItems($items); $order -> initiate(); $this -> events - > dispatch( new OrderPlaced( order: $order -> toDto(), PaymentGateway: $paymentGateway, ) ); return OrderDto::fromModel($Order); } }
  31. class PayOrder { public function __ construct( protected Bus $events,

    protected PaymentService $paymentService, ) {} public function handle(OrderPlaced $event): void { try { $payment = $this -> paymentService -> payOrder($event - > order, $event -> paymentGateway); } catch (PaymentFailedException $exception) { $this -> events - > dispatch( new PaymentFailed( orderId: $event -> order - > id, reason: $exception -> getMessage(), ) ); return; } $this - > events - > dispatch( new PaymentSucceeded( orderId: $event - > order -> id, payment: $payment - > toDto(), ) ); } }
  32. class PayOrder { public function __ construct( protected Bus $events,

    protected PaymentService $paymentService, ) {} public function handle(OrderPlaced $event): void { try { $payment = $this -> paymentService -> payOrder($event - > order, $event -> paymentGateway); } catch (PaymentFailedException $exception) { $this -> events - > dispatch( new PaymentFailed( orderId: $event -> order - > id, reason: $exception -> getMessage(), ) ); return; } $this - > events - > dispatch( new PaymentSucceeded( orderId: $event - > order -> id, payment: $payment - > toDto(), ) ); } }
  33. class PayOrder { public function __ construct( protected Bus $events,

    protected PaymentService $paymentService, ) {} public function handle(OrderPlaced $event): void { try { $payment = $this -> paymentService -> payOrder($event - > order, $event -> paymentGateway); } catch (PaymentFailedException $exception) { $this -> events - > dispatch( new PaymentFailed( orderId: $event -> order - > id, reason: $exception -> getMessage(), ) ); return; } $this - > events - > dispatch( new PaymentSucceeded( orderId: $event - > order -> id, payment: $payment - > toDto(), ) ); } }
  34. class FulfillOrder { public function handle(PaymentSucceeded $event): void { $order

    = $this - > getOrderSomehow($event -> order -> id); $order - > fulfill(); Mail::to($order -> user -> email) -> send(new OrderCompleted($order)); } }
  35. class FulfillOrder { public function handle(PaymentSucceeded $event): void { $order

    = $this - > getOrderSomehow($event -> order -> id); $order - > fulfill(); Mail::to($order -> user -> email) -> send(new OrderCompleted($order)); } }
  36. class StartShipmentForOrder { public function __ construct( protected ShipmentService $shipmentService

    ) public function handle(PaymentSucceeded $event): void { $this -> shipmentService -> startShipmentForOrder($event -> order -> id); } }
  37. class StartShipmentForOrder { public function __ construct( protected ShipmentService $shipmentService

    ) public function handle(PaymentSucceeded $event): void { $this -> shipmentService -> startShipmentForOrder($event -> order -> id); } }
  38. class DecreaseProductStock { public function __ construct( protected InventoryService $inventoryService

    ) {} public function handle(PaymentSucceeded $event): void { foreach ($event -> order - > lines as $orderLine) { $this -> inventoryService - > decreaseStockForProduct($orderLine -> productId, $orderLine -> quantity); } } }
  39. class DecreaseProductStock { public function __ construct( protected InventoryService $inventoryService

    ) {} public function handle(PaymentSucceeded $event): void { foreach ($event -> order - > lines as $orderLine) { $this -> inventoryService - > decreaseStockForProduct($orderLine -> productId, $orderLine -> quantity); } } }
  40. Tradeoffs • An additional layer of indirection • Eventual consistency

    (with queued listeners) • Need for compensatory events • Harder to reason about everything a piece of code does
  41. Can a user access this course? • Does the user

    have an order • That has an order item • That is a product • That the course is attached to
  42. Suggestions • Intra-module coupling is less “concerning" than inter-module coupling

    • Always try to expose a small, well-defined public API • Modules allow and encourage you to employ different strategies for different problems. Use that!
  43. Applying modules to Laravel • Set up a namespace to

    hold your modules (e.g \Modules) • Leverage Service Providers to set up everything related to the module: views, config files, routes, etc • Think about whether a module should hold all of its data or not • Leverage the Event Bus to pass messages between different modules
  44. Some good stuff • Shopify: Deconstructing the Monolith • Strategic

    Monoliths and Microservices ( Vaughn Vernon) • RailsConf 2023 — Applying Microservices patterns to a modular monolith ( Guillermo Aguirre) • ( Booooooo! ) My Laracasts course: Modular Laravel