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

Unveiling the Modular Monolith — Laracon EU 2024

Sponsored · SiteGround - Reliable hosting with speed, security, and support you can count on. →

Unveiling the Modular Monolith — Laracon EU 2024

Mateus Guimarães

February 06, 2024

Transcript

119 slides
  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

More Decks by Mateus Guimarães

Other Decks in Programming