close

DEV Community

Insight 105
Insight 105

Posted on

The Modular Monolith: Laravel Edition

Every architecture conversation starts the same way. Someone asks "what if we need to scale?" and before you know it there's a whiteboard covered in boxes labeled "Order Service," "Payment Service," " "User Service," and someone's already pricing out the Kubernetes cluster.

I've been that person. I've built microservices. I've maintained them. And I've learned that for 90% of business applications — POS systems, ERPs, LMS, warehouse management — the right answer is a lot simpler than what the architecture astronauts will tell you.

This is the story of how I built KANO POS, why I abandoned Clean Architecture halfway through, and why a Modular Monolith ended up being the best decision I made.


The Part Where I Sound Like a Hater

Let me say this plainly: microservices solve an organizational problem, not a technical one.

They're what you need when you have five teams that need to deploy independently. If you're a team of 3-10 developers building a business application, microservices will:

  • Slow you down because you're making network calls instead of method calls
  • Make debugging five times harder
  • Cost more money in servers and DevOps overhead
  • Give you eventual consistency headaches that ACID transactions would've solved in one line

A Modular Monolith gives you the same domain separation without any of that distributed systems tax. Your code is still organized by business capability. Your teams can still own different modules. But everything runs in one process, one database transaction, and one stack trace.


The Clean Architecture Phase (We've All Been There)

Before I landed on the structure I have now, I went through a phase that every Laravel developer eventually goes through: the Clean Architecture epiphany.

You read Robert Martin's book. You watch a few talks. Suddenly your controllers are "too fat," your models have "too many responsibilities," and you need Use Cases and Repositories and Interfaces and at least four layers of indirection before any code actually runs.

So you go all in:

app/
├── Domain/
│   ├── Entities/
│   ├── Repositories/
│   ├── Services/
│   └── ValueObjects/
├── Application/
│   ├── UseCases/
│   └── DTOs/
├── Infrastructure/
│   ├── Persistence/
│   └── External/
└── Presentation/
    └── Http/
        └── Controllers/
Enter fullscreen mode Exit fullscreen mode

I designed KANO POS exactly like this. The folder structure was beautiful. Every dependency pointed inward. Everything was testable in isolation.

It was also a maintenance nightmare.

The Interface That Had One Job (Literally)

Clean Architecture says "program to an interface, not an implementation." Sounds great. In practice, for a POS system:

  • ProductRepositoryInterface — one implementation
  • UserRepositoryInterface — one implementation
  • SaleRepositoryInterface — one implementation

Every single repository interface in my codebase had exactly one implementation. I was writing ten lines of interface boilerplate for every thirty lines of actual code. None of it was ever polymorphic. None of it ever needed to be.

The argument is always "you can swap implementations for testing." But Laravel already has RefreshDatabase for that. You don't need to mock a repository when you can just run the query against SQLite and get a real result.

The Use Case That Was Just a Fancy Middleman

class CreateProductUseCase
{
    public function __construct(
        private ProductRepositoryInterface $productRepo
    ) {}

    public function execute(CreateProductDTO $dto): Product
    {
        return $this->productRepo->create($dto->toArray());
    }
}
Enter fullscreen mode Exit fullscreen mode

That's a controller with a suit and tie. I had dozens of these — classes that existed solely to call one method on another class. They added zero business value and made me open four files to understand a three-line operation.

The Repository That Hid Eloquent

This was the worst one. A repository that wraps Eloquent and exposes find(), create(), update(). The entire point of Eloquent is that it already is a repository pattern. ActiveRecord with a really clean query API. Wrapping it in another layer meant:

  • Every new query needed a new repository method
  • Eager loading relationships needed explicit method calls
  • Scopes became useless unless the repository exposed them
  • Debugging meant tracing through three layers to find the actual SQL

The Breaking Point

I needed to add a simple report — total sales per cashier per shift. In Clean Architecture:

  1. Create a new DTO
  2. Add a method to the repository interface
  3. Implement that method in the concrete repository
  4. Create a new Use Case
  5. Wire it up in a Controller

Five files for a single GROUP BY query.

In the modular monolith, it's one controller that calls DB::raw() and returns a collection. The query lives next to the data. When the business logic changes a year later, I don't need to trace through four layers to find where to edit.

What I Kept, What I Threw Away

I didn't abandon everything. Some Clean Architecture ideas are genuinely good. Here's what survived the purge:

Concept What happened
Domain separation (bounded contexts) Kept — but as modules, not layers
Interfaces for polymorphism Kept — only when I actually need them (Shared Contracts)
Repository pattern Dropped — Eloquent does this already
Use Cases Dropped — Services handle business logic fine
DTOs Kept — but only at module boundaries
Dependency injection Kept — Laravel's container, nothing extra needed

The lesson is simple: Clean Architecture is a tool, not a prescription. If a layer doesn't earn its keep, it's not "clean" — it's bloat. The modular monolith gave me the same domain isolation without the ceremony.


What I Ended Up With

Here's the module structure that replaced all that Clean Architecture ceremony. These are the business domains, organized as self-contained modules:

modules/
├── Accounting/     # Ledger, journals, COA, financial reports
├── Approval/       # Workflow for purchase requisitions, discounts
├── Auth/           # Login, roles, permissions
├── Inventory/      # Stock, mutations, warehouse transfers
├── Master/         # Products, categories, price lists
├── Media/          # File uploads, images
├── Purchase/       # Purchase orders, goods receipt, suppliers
├── Report/         # Aggregated reports
├── Sales/          # POS, invoices, returns, payments
├── Shared/         # Contracts, middleware, cross-cutting stuff
└── Tenant/         # Multi-tenant management, schema isolation
Enter fullscreen mode Exit fullscreen mode

Each module is its own Laravel "package" inside the app:

Sales/
├── Console/        # Artisan commands
├── DTOs/           # Data Transfer Objects
├── Database/       # Migrations, seeders
│   ├── Migrations/
│   └── Seeders/
├── Enums/          # Domain enums
├── Events/         # Domain events
├── Http/
│   ├── Controllers/
│   ├── Requests/
│   └── Resources/
├── Models/
├── Policies/
├── Providers/
├── Routes/
└── Services/
Enter fullscreen mode Exit fullscreen mode

The golden rule: A module never imports another module's Models or Services directly. Communication happens through Shared Contracts.


How Modules Actually Talk to Each Other

Auto-Discovery (So You Don't Have to Register Anything)

I didn't want to manually register each module every time I created one. So there's one provider that finds them all:

class ModularServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        foreach (glob(base_path('modules/*/Providers/*ServiceProvider.php')) as $provider) {
            $class = $this->resolveProviderClass($provider);
            if ($class && $class !== static::class) {
                $this->app->register($class);
            }
        }
    }

    public function boot(): void
    {
        Route::prefix('api/v1')
            ->middleware('log_api_request')
            ->group(function () {
                foreach (glob(base_path('modules/*/Routes/api.php')) as $routeFile) {
                    $this->loadRoutesFrom($routeFile);
                }
            });
    }
}
Enter fullscreen mode Exit fullscreen mode

Create a new module directory, add a Provider, add your routes. Done. Nothing to configure.

Shared Contracts (The Only Way Modules Touch Each Other)

If Sales needs to check stock, it doesn't import Inventory\Models\StockMovement. It uses an interface:

// Modules/Shared/Contracts/StockServiceInterface.php
interface StockServiceInterface
{
    public function checkAvailability(int $itemId, int $warehouseId, float $qty): bool;
    public function decreaseStock(int $itemId, int $warehouseId, float $qty, string $type, ...): void;
    public function increaseStock(int $itemId, int $warehouseId, float $qty, string $type, ...): void;
}
Enter fullscreen mode Exit fullscreen mode

Inventory implements it. Sales consumes it through the interface. No circular dependencies, no coupling to Inventory's internal models, easy to mock in tests.

Domain Events for Side Effects

When a sale happens, a bunch of things need to happen:

  1. Save the invoice (Sales)
  2. Deduct stock (Inventory)
  3. Write journal entries (Accounting)

In microservices, you'd send events to a message queue. In a modular monolith, you use Laravel events:

// Sales dispatches an event
SaleCompleted::dispatch($sale);

// Inventory listens (inside its own module)
class DeductStockOnSale
{
    public function handle(SaleCompleted $event): void
    {
        // Decrease stock using Inventory's own services
    }
}

// Accounting listens (inside its own module)
class CreateJournalEntryOnSale
{
    public function handle(SaleCompleted $event): void
    {
        // Write journal entries
    }
}
Enter fullscreen mode Exit fullscreen mode

Sales has no idea Inventory or Accounting exist. If a listener fails, the whole transaction rolls back. No eventual consistency nightmares.

DTOs Keep Boundaries Clean

Passing Eloquent models across modules creates hidden coupling. So at every module boundary, I use DTOs:

class SaleData
{
    public function __construct(
        public readonly string $customerName,
        public readonly array $items,
        public readonly float $total,
        public readonly string $paymentMethod,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

If the underlying model changes, the DTO absorbs the impact. Other modules never know.


Multi-Schema Tenants (Without the Pain)

For a POS system, tenant isolation isn't optional. Here's the setup:

Database: pos_system
├── public/           # Global data (tenants, plans)
├── tenant_001/       # Tenant 1's schema
│   ├── sales
│   ├── inventory
│   ├── accounting
│   └── ...
├── tenant_002/
└── tenant_003/
Enter fullscreen mode Exit fullscreen mode

Each tenant gets their own PostgreSQL schema, completely isolated. The middleware sets the search path before every request:

class ResolveTenant
{
    public function handle(Request $request, Closure $next)
    {
        $tenant = auth()->user()->tenant;
        config(['database.connections.pgsql.search_path' => "{$tenant->schema},public"]);
        return $next($request);
    }
}
Enter fullscreen mode Exit fullscreen mode

No where tenant_id = ? on every query. Full data isolation. Backup per tenant is just a schema dump. One connection pool for all tenants.

One thing I learned the hard way: be careful with raw search_path manipulation if you're running PgBouncer in transaction mode. That was a fun debugging session.


The Traps I Fell Into (So You Don't Have To)

The Shared Module Blob

The Shared module starts as a clean little place for contracts and middleware. Then someone puts a helper function in it. Then a service that "might be useful to other modules." Before you know it, Shared is a junk drawer.

Rule: If a class references a specific business domain, it doesn't belong in Shared. StockServiceInterface is fine (it's a contract). PriceCalculator that contains pricing logic? Belongs in Master or Sales, not Shared.

Circular Dependencies

Sales calls Inventory, Inventory calls Master, Master calls... wait.

Solution: Enforce dependency direction. In my POS:

  • Master depends on nothing
  • Sales depends on Master and Shared
  • Inventory depends on Master and Shared
  • Accounting depends on Sales, Inventory, Master, and Shared

No module should depend on a "higher" module. If you hit a circular dependency, extract the shared logic into a new Shared contract.

Testing Everything Together

With 12 modules loaded, PHPUnit starts feeling slow.

What worked: Use SQLite in-memory for unit tests. Mock Shared Contracts aggressively. Separate unit and feature test suites. Run --parallel when the suite gets big.

Migration Timing

Two devs add migrations on the same day. Both timestamped 2025_06_15_000001. One fails.

Solution: Use a naming convention — timestamp plus zero-padded sequence number. Review PRs that touch multiple modules' migrations. Keep migrations small and isolated.

The "But We'll Need Microservices" Trap

Don't add message queues, event sourcing, and CQRS "just in case" you split it later. If you eventually need microservices, extracting a module is straightforward:

  1. Copy the module into a standalone Laravel app
  2. Turn its Shared Contracts into HTTP calls
  3. Deploy

Starting with microservices infrastructure "just in case" guarantees you'll have all the complexity with none of the benefits.


So When Do You Actually Need Microservices?

The modular monolith isn't always the answer. Go microservices when:

  • You have multiple teams that need to deploy independently
  • Different parts of your system need different scaling (Sales needs 20 instances, Report needs 1)
  • You genuinely need different databases for different parts of your system
  • Regulations force data isolation at the infrastructure level
  • You have a dedicated DevOps person (or team)

For everyone else? Start modular. Stay modular. Only split when you have to.


The Bottom Line

Here's the honest comparison:

Modular Monolith Microservices
Deployment One deploy N deploys
Debugging One stack trace Tracing through 5 services
Data integrity ACID transactions "Well, eventually..."
Team autonomy Module boundaries Service boundaries
DevOps work Minimal Significant
Refactoring Easy "Let's just rewrite it"
Shipping speed Fast "Once the pipeline is done"

For a POS system, an ERP, a warehouse app, or any business software where data integrity matters more than infinite scalability — this is the sweet spot.

KANO POS runs this architecture in production. Thousands of transactions daily across dozens of tenants. Testable, maintainable, and when something breaks, I can find the bug in one stack trace instead of spelunking through five services.

The best architecture is the one that helps you ship features today without locking you into a corner tomorrow.


Built with Laravel 13, PostgreSQL multi-schema.

If you've gone through the same Clean Architecture → Modular Monolith journey, I'd love to hear what you kept and what you threw away. Drop a comment below.

Top comments (0)