Organizing code by feature within a monolith can actually make it more scalable than a microservices architecture, provided you get the module boundaries right.
Let’s look at a typical e-commerce monolith. We’ve got a Users module, an Orders module, a Products module, and a Payments module. Each of these is a distinct, self-contained unit of functionality.
Imagine a user browsing products, adding them to their cart, and then proceeding to checkout.
User Browsing:
The Products module handles displaying product listings and details. It might have controllers like ProductController with actions like show(productId) and list(categoryId). Data access might involve a ProductRepository querying a products table.
Adding to Cart:
This often involves a Cart or Orders module. A CartController might have an addItem(productId, quantity) action. This action needs to interact with the Products module to get product details (like price and availability) and then update the user’s session or a dedicated carts table.
Checkout:
This is where things get interesting and modules must collaborate. The Orders module takes over. It orchestrates the process:
- Get Cart Contents: Calls the
Cartmodule to retrieve the items. - Validate Inventory: Interacts with the
Productsmodule to ensure items are still in stock. - Process Payment: Calls the
Paymentsmodule, passing in the order total and payment details. - Create Order: If payment is successful, the
Ordersmodule creates a new order record, linking it to the user and the purchased products. - Update Inventory: Calls the
Productsmodule to decrement stock levels.
Here’s a simplified Orders module structure:
app/
├── modules/
│ ├── users/
│ │ ├── UserController.php
│ │ ├── UserRepository.php
│ │ └── User.php
│ ├── products/
│ │ ├── ProductController.php
│ │ ├── ProductRepository.php
│ │ └── Product.php
│ ├── orders/
│ │ ├── OrderController.php
│ │ ├── OrderService.php // Orchestrates order creation
│ │ ├── OrderRepository.php
│ │ └── Order.php
│ └── payments/
│ ├── PaymentController.php
│ ├── PaymentService.php
│ └── PaymentGateway.php
└── bootstrap/
└── app.php
The OrderService in the orders module might look something like this:
<?php
namespace App\Modules\Orders;
use App\Modules\Products\ProductRepository;
use App\Modules\Payments\PaymentService;
use App\Modules\Users\UserRepository;
use App\Modules\Cart\CartService; // Assuming a Cart module exists
class OrderService
{
protected $productRepository;
protected $paymentService;
protected $userRepository;
protected $cartService;
protected $orderRepository;
public function __construct(
ProductRepository $productRepository,
PaymentService $paymentService,
UserRepository $userRepository,
CartService $cartService,
OrderRepository $orderRepository
) {
$this->productRepository = $productRepository;
$this->paymentService = $paymentService;
$this->userRepository = $userRepository;
$this->cartService = $cartService;
$this->orderRepository = $orderRepository;
}
public function createOrder(int $userId, array $paymentDetails): Order
{
$cartItems = $this->cartService->getCartItems($userId);
$orderTotal = 0;
// 1. Validate products and calculate total
foreach ($cartItems as $item) {
$product = $this->productRepository->findById($item['productId']);
if (!$product || $product->stock < $item['quantity']) {
throw new \Exception("Product {$product->name} is out of stock.");
}
$orderTotal += $product->price * $item['quantity'];
}
// 2. Process payment
$paymentResult = $this->paymentService->process($paymentDetails, $orderTotal);
if (!$paymentResult->success) {
throw new \Exception("Payment failed: {$paymentResult->message}");
}
// 3. Create order
$user = $this->userRepository->findById($userId);
$order = new Order();
$order->userId = $userId;
$order->total = $orderTotal;
$order->status = 'paid'; // Or 'processing'
$this->orderRepository->save($order);
// 4. Create order items and update inventory
foreach ($cartItems as $item) {
$orderItem = new OrderItem(); // Assuming OrderItem model
$orderItem->orderId = $order->id;
$orderItem->productId = $item['productId'];
$orderItem->quantity = $item['quantity'];
$orderItem->price = $item['price']; // Price at time of order
$this->orderRepository->saveOrderItem($orderItem);
// Update inventory
$this->productRepository->decrementStock($item['productId'], $item['quantity']);
}
// 5. Clear user's cart
$this->cartService->clearCart($userId);
return $order;
}
}
The key here is that each module exposes a clear API (e.g., ProductRepository methods, OrderService methods) that other modules can depend on. Dependencies are inward towards core modules like Products and Users, and outward from orchestrating modules like Orders and Payments. This creates a dependency graph where changes within a module have predictable impacts.
The one thing that often gets overlooked in modular monoliths is the definition of module boundaries. If you make Products depend on Orders (e.g., ProductRepository needing to know about order history to recommend related products), you’ve created a circular dependency, and your "modules" start to bleed into each other, turning back into a messy ball of mud. Strict adherence to single-responsibility and clear, unidirectional dependencies are crucial.
The next challenge is effectively managing inter-module communication, especially for asynchronous events.