Advanced Laravel

Modular Monolith Architecture

18 min Lesson 31 of 40

Introduction to Modular Monolith Architecture

Modular Monolith Architecture combines the simplicity of monolithic applications with the organizational benefits of modular design. It provides a pathway to microservices without the immediate complexity of distributed systems.

Key Concept: A modular monolith organizes code into independent modules with clear boundaries, each representing a business domain or bounded context, while maintaining a single deployable unit.

Why Modular Monolith?

Traditional monolithic applications often become difficult to maintain as they grow. Modular monolith architecture addresses this by:

  • Clear Boundaries: Each module has well-defined responsibilities and interfaces
  • Independent Development: Teams can work on different modules with minimal conflicts
  • Easier Testing: Modules can be tested in isolation
  • Migration Path: Modules can be extracted into microservices when needed
  • Simpler Operations: Single deployment unit, shared database, no network latency

Module Structure in Laravel

A typical Laravel modular monolith organizes modules within the app/Modules directory:

app/ ├── Modules/ │ ├── User/ │ │ ├── Models/ │ │ │ └── User.php │ │ ├── Controllers/ │ │ │ ├── UserController.php │ │ │ └── ProfileController.php │ │ ├── Repositories/ │ │ │ └── UserRepository.php │ │ ├── Services/ │ │ │ └── UserService.php │ │ ├── Events/ │ │ │ └── UserRegistered.php │ │ ├── Listeners/ │ │ │ └── SendWelcomeEmail.php │ │ ├── Routes/ │ │ │ ├── api.php │ │ │ └── web.php │ │ ├── Database/ │ │ │ ├── Migrations/ │ │ │ └── Seeders/ │ │ ├── Tests/ │ │ │ ├── Unit/ │ │ │ └── Feature/ │ │ └── Providers/ │ │ └── UserServiceProvider.php │ ├── Order/ │ │ └── ... (similar structure) │ ├── Payment/ │ │ └── ... (similar structure) │ └── Shared/ │ └── ... (shared utilities)

Creating a Module Service Provider

Each module should have its own service provider to register routes, bindings, and listeners:

<?php namespace App\Modules\User\Providers; use Illuminate\Support\ServiceProvider; use Illuminate\Support\Facades\Route; class UserServiceProvider extends ServiceProvider { /** * Register module services */ public function register(): void { // Register module repositories $this->app->bind( 'App\Modules\User\Repositories\UserRepositoryInterface', 'App\Modules\User\Repositories\UserRepository' ); // Register module services $this->app->singleton( 'App\Modules\User\Services\UserService' ); } /** * Bootstrap module services */ public function boot(): void { // Load module migrations $this->loadMigrationsFrom(__DIR__ . '/../Database/Migrations'); // Load module views $this->loadViewsFrom(__DIR__ . '/../Resources/views', 'user'); // Load module translations $this->loadTranslationsFrom(__DIR__ . '/../Resources/lang', 'user'); // Register module routes $this->registerRoutes(); // Register module event listeners $this->registerEventListeners(); } /** * Register module routes */ protected function registerRoutes(): void { Route::middleware('web') ->group(__DIR__ . '/../Routes/web.php'); Route::middleware('api') ->prefix('api') ->group(__DIR__ . '/../Routes/api.php'); } /** * Register event listeners */ protected function registerEventListeners(): void { $this->app['events']->listen( 'App\Modules\User\Events\UserRegistered', 'App\Modules\User\Listeners\SendWelcomeEmail' ); } }
Pro Tip: Register all module service providers in config/app.php or use auto-discovery by following Laravel's package conventions.

Inter-Module Communication

Modules should communicate through well-defined interfaces to maintain loose coupling. There are several patterns for inter-module communication:

1. Service Contract Pattern

Define interfaces in the Shared kernel that modules can implement:

<?php namespace App\Modules\Shared\Contracts; interface NotificationServiceInterface { public function send(string $userId, string $message): void; public function sendBatch(array $userIds, string $message): void; } // Implementation in Notification module namespace App\Modules\Notification\Services; use App\Modules\Shared\Contracts\NotificationServiceInterface; class NotificationService implements NotificationServiceInterface { public function send(string $userId, string $message): void { // Implementation } public function sendBatch(array $userIds, string $message): void { // Implementation } } // Usage in Order module namespace App\Modules\Order\Services; use App\Modules\Shared\Contracts\NotificationServiceInterface; class OrderService { public function __construct( private NotificationServiceInterface $notificationService ) {} public function createOrder(array $data): Order { $order = Order::create($data); // Use the notification service without knowing implementation $this->notificationService->send( $order->user_id, "Your order #{$order->id} has been created" ); return $order; } }

2. Event-Driven Communication

Use Laravel events for asynchronous, decoupled communication between modules:

<?php // Order module publishes event namespace App\Modules\Order\Events; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; class OrderPlaced { use Dispatchable, SerializesModels; public function __construct( public int $orderId, public int $userId, public float $amount ) {} } // Payment module listens to event namespace App\Modules\Payment\Listeners; use App\Modules\Order\Events\OrderPlaced; class ProcessPayment { public function handle(OrderPlaced $event): void { // Process payment for the order // This module doesn't need to know about Order module internals } } // Notification module listens to the same event namespace App\Modules\Notification\Listeners; use App\Modules\Order\Events\OrderPlaced; class SendOrderConfirmation { public function handle(OrderPlaced $event): void { // Send order confirmation notification } }
Warning: Avoid circular dependencies between modules. If Module A depends on Module B, Module B should never depend on Module A. Use events or shared interfaces to break circular dependencies.

Shared Kernel

The Shared kernel contains code that multiple modules need to access. It should be minimal and carefully managed:

app/Modules/Shared/ ├── Contracts/ # Interfaces for inter-module communication ├── ValueObjects/ # Shared value objects (Money, Email, etc.) ├── Enums/ # Shared enumerations ├── Exceptions/ # Shared exception classes ├── Traits/ # Shared traits └── Helpers/ # Shared helper functions

Example of a shared value object:

<?php namespace App\Modules\Shared\ValueObjects; class Money { private function __construct( private float $amount, private string $currency ) {} public static function make(float $amount, string $currency = 'USD'): self { if ($amount < 0) { throw new \InvalidArgumentException('Amount cannot be negative'); } return new self($amount, strtoupper($currency)); } public function amount(): float { return $this->amount; } public function currency(): string { return $this->currency; } public function add(Money $other): self { if ($this->currency !== $other->currency) { throw new \InvalidArgumentException('Currency mismatch'); } return new self($this->amount + $other->amount, $this->currency); } public function format(): string { return number_format($this->amount, 2) . ' ' . $this->currency; } }

Bounded Contexts

Each module represents a bounded context from Domain-Driven Design. A bounded context defines clear boundaries within which a model is applicable:

Example: The "User" entity might have different meanings in different contexts:
  • User Module: Focuses on authentication, profile, preferences
  • Order Module: Views user as a buyer with shipping addresses
  • Support Module: Views user as a ticket creator with support history
Each module maintains its own representation of "User" appropriate to its context.

Exercise 1: Create a Module Structure

Create a "Product" module with the following structure:

  1. Create the module directory structure with Models, Controllers, Services, and Providers
  2. Create a ProductServiceProvider that loads routes and registers bindings
  3. Create a Product model and migration
  4. Register the service provider in config/app.php

Exercise 2: Implement Inter-Module Communication

Implement communication between Order and Inventory modules:

  1. Create an OrderPlaced event in the Order module
  2. Create a DecrementStock listener in the Inventory module
  3. Register the listener to handle the event
  4. Test that placing an order decrements inventory

Exercise 3: Create a Shared Value Object

Create a reusable Email value object in the Shared kernel:

  1. Create an Email class with validation in the constructor
  2. Add methods: isValid(), domain(), toString()
  3. Use the Email value object in both User and Newsletter modules
  4. Write tests for the Email value object

Benefits of Modular Monolith

  • Team Scalability: Multiple teams can work independently on different modules
  • Code Organization: Clear boundaries make the codebase easier to navigate
  • Testing: Modules can be tested in isolation with mocked dependencies
  • Gradual Migration: Modules can be extracted into microservices when needed
  • Performance: No network overhead between modules like in microservices
  • Simplified Deployment: Single deployment unit reduces operational complexity
Best Practice: Start with a modular monolith and only move to microservices when you have clear evidence that the benefits outweigh the complexity costs.