Yu Wu Hsien - Profile Picture
YU WU HSIEN

Just simple folk, with HTML, trying to make a living.

Refactoring Part 1: From Monolith to Modular

Five years ago, I built an inventory management system with Laravel to support our company's early-stage operations. The initial scope was clear: procurement management, inventory tracking, and order processing. Thanks to Laravel's rapid development capabilities, the system went live quickly and gained approval.

That's the beauty of Laravel — it lets you turn ideas into working products quickly, and you get plenty of feedback early on.

However, as the business grew, so did the system requirements. Over five years, we added multi-tenancy, pharmaceutical batch tracking, vendor management, third-party invoice integration, sales reports, and gradually integrated pharmacy retail channels, e-commerce shopping carts, and POS systems. The system became larger and increasingly complex.

Today, I'm dealing with a large application containing dozens of business modules, hundreds of class files, and several scattered subsystems.

Challenges

Directory Structure

Initially, the system followed Laravel's standard directory structure. This type-based organization worked well in the early stages:

app/
├── Http/Controllers/
├── Models/
├── Jobs/
└── Listeners/

But as business modules grew, problems began to emerge. Take the procurement module as an example — its code was scattered across multiple directories:

app/Http/Controllers/PurchaseOrderController.php
app/Models/PurchaseOrder.php
app/Jobs/ProcessPurchaseOrder.php
app/Listeners/PurchaseOrderCreated.php
app/Notifications/PurchaseOrderNotification.php

Code for a single business module was spread across five (or more) different directories.

Looking further at the app/Models/ directory:

app/Models/
├── User.php
├── Order.php
├── Invoice.php
├── PurchaseOrder.php
├── ScrapOrder.php
├── Stock.php
├── Product.php
└── ...(30+ Models)

These Models shared nothing beyond being the same type. Invoice and ScrapOrder sat in the same directory simply because they were both Models, yet they belonged to completely different business domains.

Maintaining Subsystems

As the business expanded, we developed three subsystems:

  • Admin Panel: For internal staff to manage inventory and orders
  • E-commerce Platform: For customers to shop online
  • Retail POS: To handle physical store sales

These subsystems started as separate projects communicating via APIs. While we extracted some shared logic into independent packages, time pressure and growing business demands led to frequent duplicate implementations:

php
// Main system
class InventoryService {
    public function calculateStock($productId) {
        // Inventory calculation logic
    }
}

// POS system
class PosInventoryService {
    public function calculateStock($productId) {
        // Similar inventory calculation logic
    }
}

// E-commerce system
class CommerceInventoryService {
    public function calculateStock($productId) {
        // Re-implementing similar logic again
    }
}

The same business logic was duplicated across different systems. Even when they differed only slightly, this increased maintenance costs and the likelihood of errors.

Attempts

I tried creating business subdirectories under each type directory:

app/
├── Http/Controllers/
│   ├── Inventory/
│   ├── Procurement/
│   └── Order/
├── Models/
│   ├── Inventory/
│   ├── Procurement/
│   └── Order/
└── Jobs/
    ├── Inventory/
    ├── Procurement/
    └── Order/

This approach did improve file organization, but in practice, I still had to switch between multiple type directories to complete a single feature modification. For example, when modifying the procurement feature:

Http/Controllers/Procurement/PurchaseOrderController.php
Models/Procurement/PurchaseOrder.php
Jobs/Procurement/PurchaseOrderCreated.php
Listeners/Procurement/PurchaseOrderCreated.php
Notifications/Procurement/PurchaseOrderNotification.php

I was still jumping between multiple directories — just with an extra layer added to each.

The Question

This made me wonder: Do we have to organize code by type?

When developing specific business features, I want to focus on the completeness of business logic, not on distinguishing between Controllers, Models, and Jobs. If I could group all related classes of the same business module in one directory, I could focus more on the module itself during development.

If we don't have to organize code by type, does that mean we must strictly follow Domain-Driven Design (DDD), implementing concepts like Bounded Context, Aggregate Root, and Domain Events?

Is there a middle ground that balances practical needs with architectural complexity?

Industry Practices

While searching for solutions, I found that many developers had encountered similar issues.

Jeffrey Way's experience shared on Twitter validated my thinking:

A really useful technique for slightly larger projects is to build and organize app features as if you were making a Composer package. Group all of your related models, controllers, listeners, commands, etc. under the same directory and namespace.

He suggested organizing related Models, Controllers, Listeners, Commands, etc., under the same directory and namespace, just like developing a Composer package.

Additionally, Mateus Guimarães shared his practical experience in Modularizing the monolith: a real-world experience, and Shopify's engineering team discussed similar architectural evolution in Deconstructing the Monolith.

These practical experiences convinced me that organizing code by business domain is the more sensible choice.

Restructuring

Overall Structure

Based on these ideas, I restructured the architecture as follows:

modules/
├── src/
│   ├── Apps/              # Application Layer
│   │   ├── Admin/         # Admin Panel
│   │   ├── Store/         # Retail System
│   │   └── Commerce/      # E-commerce Platform
│   ├── Core/              # Core Shared Logic
│   │   ├── User/
│   │   └── Organization/
│   ├── Customer/          # Customer Management
│   ├── Procurement/       # Procurement Management
│   ├── Inventory/         # Inventory Management
│   ├── Finance/           # Finance Management
│   └── Infrastructure/    # Infrastructure
├── config/                # Module configuration files
├── database/              # Database migrations
├── routes/                # Route definitions
├── resources/             # Views and assets
└── tests/                 # Test files

In this new architecture, config, database, routes, and other directories are not placed under individual subsystems, but unified at the modules root. The main reason is that these directories manage shared resources across modules:

  • database/migrations: Database schema changes for all modules need unified version management
  • routes/: Although organized by subsystem in separate files, centralized placement makes the overall routing structure easier to review
  • config/: Module-level configurations that subsystems access through Service Providers
  • tests/: Testing strategy is managed centrally while still organized by module

Centrally managing shared resources while maintaining module independence strikes a good balance in practice.

Module Structure

Each business module organizes all related classes internally. Take the procurement module as an example:

modules/src/Procurement/
├── Http/
│   └── Controllers/
│       ├── PurchaseOrderController.php
│       └── ReplenishmentController.php
├── Jobs/
│   └── FilterReplenishmentCandidate.php
├── Listeners/
│   └── LogReplenishmentHistory.php
├── Models/
│   ├── PurchaseOrder.php
│   └── PurchaseOrderItem.php
├── Enums/
│   ├── ReplenishmentReason.php
│   └── ReplenishmentStatus.php
└── ReplenishmentManager.php

The namespace also becomes much clearer when using it:

php
use App\Models\PurchaseOrder;           // Before
use Modules\Procurement\PurchaseOrder;  // After

From the namespace alone, it's clear that PurchaseOrder belongs to the procurement module, and all related classes can be found in the Procurement directory. This organization allows developers to focus on a single business domain during development, without having to switch back and forth between multiple type-based directories.

Subsystem Architecture

All subsystems are placed under modules/src/Apps/:

modules/src/Apps/
├── Admin/
│   ├── Http/
│   ├── Models/
│   ├── Actions/
│   └── AdminServiceProvider.php
├── Store/
│   ├── Http/
│   ├── Models/
│   └── StoreServiceProvider.php
└── Commerce/
    ├── Http/
    ├── Models/
    └── CommerceServiceProvider.php

Each subsystem manages its own routes, authentication, and configuration through its Service Provider, remaining independent. At the same time, they can:

  • Share core module business logic
  • Inherit and extend existing functionality
  • Override specific implementations when needed

All systems are maintained in the same project, effectively avoiding code duplication.

Composer Configuration

Register the module namespaces in composer.json:

json
{
  "autoload": {
    "psr-4": {
      "App\\": "app/",
      "Modules\\": "modules/src/"
    }
  },
  "autoload-dev": {
    "psr-4": {
      "Tests\\": "tests/",
      "Modules\\Tests\\": "modules/tests/"
    }
  }
}

Benefits

Improved Development Efficiency

Before: Modifying procurement features required switching between multiple directories

app/Http/Controllers/...
app/Models/...
app/Jobs/...
app/Listeners/...

After: All related files are concentrated in a single directory

modules/src/Procurement/

Code Reuse

Before: Each subsystem maintained similar logic independently*

php
Admin/InventoryService.php
Store/InventoryService.php
Commerce/InventoryService.php

After: Shared core modules

php
// modules/src/Inventory/InventoryService.php
namespace Modules\Inventory;

class InventoryService { ... }

// Direct usage in subsystems
use Modules\Inventory\InventoryService;

Extensibility

When adding a logistics system, simply create a new directory under modules/src/Apps/:

modules/src/Apps/
└── Logistics/
    ├── Http/
    ├── Models/
    └── LogisticsServiceProvider.php

The new system can immediately reuse all existing business module logic.

Final Thoughts

Evolving from the standard Laravel directory structure to a modular architecture was a long but worthwhile process. This had to be done while keeping the company running, adding new features, and adjusting existing ones — it wasn't an uninterrupted task.

The best time to refactor is often later than we think. As we build systems, we continuously deepen our understanding of the business domain. Designing complex system architectures or microservices without domain knowledge only adds unnecessary risk and cost.

David Heinemeier Hansson also explored this topic in The Majestic Monolith. He's not against good software practices, but reminds us: don't spend weeks or even months designing a system you don't fully understand yet, especially when it might remain a small project.

Architecture is the result of evolution, not a perfect blueprint designed upfront.

The right architectural solution depends on team size, application complexity, and actual operational conditions. No matter which architecture you choose, you'll go through a painful and challenging period during evolution. But when development efficiency drops and system modifications become difficult, it might be time to rethink.

This refactoring let me experience the full cycle firsthand: appreciating the convenience of monolithic architecture, recognizing it gradually becoming a bottleneck, and finally thinking through and implementing change. This wasn't a top-down decision, but a deep understanding based on actual pain points.

The goal of refactoring isn't to pursue theoretically perfect architecture, but to make code easier to understand, maintain, and extend. There's no absolutely best architecture, only the architecture that best fits the current context and team.

© 2025 Yu Wu Hsien.