Share
Embed
Unveiling the Modular Monolith — Laracon EU 2024
Mateus Guimarães
February 06, 2024
Transcript
119 slides
-
Mateus Guimarães Unveiling the Modular Monolith Attacking complexity in monolithic
Laravel applications Developer and Educator @mateusjatenee Laracon EU 2024 -
Mateus Guimarães Software Developer & Educator • Powerlifting • Playing
guitar • Photography • Content creation • Spending time with my wife @mateusjatenee MateusGuimarães -
The Monolith The good parts • Everyone knows them •
Simpler infrastructure • Simple method calls • ( Usually) one database • Database Transactions • One artifact • Easier to debug -
The Monolith The bad parts • Tight coupling between components
• No well-defined boundaries • Hard to fully understand • Difficult to maintain • Hard to onboard -
Microservices The good parts • Easy to scale • Well-defined
boundaries • Independent deployment • Resilience -
Microservices The bad parts • Multiple repositories • Complex infrastructure
• Network calls • Multiple databases • Consistency • Harder to debug • Distributed Transactions -
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 -
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 -
{ "autoload": { "psr-4": { "App\\": "app/", "Database\\Factories\\": "database/factories/", "Database\\Seeders\\":
"database/seeders/" "Modules\\": "modules/" } } } -
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); -
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); -
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); -
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 -
Data Transfer Objects • Bags of data • No behavior
• Immutable • Plain Old PHP Objects • Meant to be passed around! • It’s… a contract! -
readonly class PendingPayment { public function __ construct( public string
$id, public Token $token, public Cents $totalInCents, public Gateway $gateway, public string $gatewayId, ) {} } -
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); } } -
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); } } -
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); } } -
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); } } -
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); } } -
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); } } -
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); } } -
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); } } -
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; } } -
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; } } -
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; } } -
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; } } -
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; } } -
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; }); } } -
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); } } -
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); } } -
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(), ) ); } } -
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(), ) ); } } -
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(), ) ); } } -
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)); } } -
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)); } } -
class StartShipmentForOrder { public function __ construct( protected ShipmentService $shipmentService
) public function handle(PaymentSucceeded $event): void { $this -> shipmentService -> startShipmentForOrder($event -> order -> id); } } -
class StartShipmentForOrder { public function __ construct( protected ShipmentService $shipmentService
) public function handle(PaymentSucceeded $event): void { $this -> shipmentService -> startShipmentForOrder($event -> order -> id); } } -
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); } } } -
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); } } } -
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 -
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 -
Can a student access this course? • Does the student
have the course unlocked? -
Modularization encourages you to actively think about your systems and
their relationships. -
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! -
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 -
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