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:

  1. Get Cart Contents: Calls the Cart module to retrieve the items.
  2. Validate Inventory: Interacts with the Products module to ensure items are still in stock.
  3. Process Payment: Calls the Payments module, passing in the order total and payment details.
  4. Create Order: If payment is successful, the Orders module creates a new order record, linking it to the user and the purchased products.
  5. Update Inventory: Calls the Products module 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.

Want structured learning?

Take the full Monolith course →