DEV Community

Olamilekan Lamidi
Olamilekan Lamidi

Posted on

From Monolith to Modular: A Strangler Migration Playbook

Every successful application eventually outgrows its original architecture. What started as a clean, well-structured monolith becomes a tangled web of dependencies where changing one feature risks breaking three others. Deployments become risky, test suites take forever, and developers spend more time navigating the codebase than writing new features.

The instinct is to rewrite. Do not. Rewrites are how engineering teams burn months of effort and deliver something that, at best, does what the old system already did. The better approach is incremental migration: systematically extracting capabilities from the monolith while keeping the system running in production the entire time.

I have led these migrations across multiple companies — taking legacy codebases with 15+ modules, hundreds of database tables, and years of accumulated technical debt and transforming them into modular, maintainable architectures without downtime. This article is the playbook.


Why Monoliths Become Painful

Monoliths are not inherently bad. They are fast to build, easy to deploy, and simple to reason about — at small scale. The problems emerge gradually:

  1. Deployment coupling: Every change, no matter how small, requires deploying the entire application. A CSS fix and a payment logic change ship in the same deployment.

  2. Dependency tangles: Module A depends on Module B which depends on Module C which depends on Module A. Circular dependencies make changes unpredictable.

  3. Test suite slowdown: Running the full test suite takes 30+ minutes. Developers start skipping tests. Bugs escape to production.

  4. Team bottlenecks: Multiple teams working on the same codebase create merge conflicts, code review backlogs, and release coordination overhead.

  5. Scaling limitations: You cannot scale the payment processing component independently of the user profile component. Everything scales (or fails) together.


The Strangler Fig Pattern

The strangler fig pattern, named after the plant that gradually envelops its host tree, is the safest approach to migrating away from a monolith. The strategy:

  1. Identify a capability to extract from the monolith
  2. Build the new implementation alongside the old one
  3. Route traffic to the new implementation (gradually or all at once)
  4. Remove the old implementation once the new one is proven
  5. Repeat for the next capability

The monolith continues to run throughout. There is no big bang cutover. Each migration step is small, reversible, and independently testable.


Phase 1: Understanding What You Have

Before you extract anything, you need a map of the current system.

Dependency Analysis

# Laravel: Find all cross-module dependencies
# Look for imports/use statements between modules
Enter fullscreen mode Exit fullscreen mode

Laravel Module Boundary Analysis

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;

class AnalyzeModuleDependencies extends Command
{
    protected $signature = 'modules:analyze';

    public function handle(): void
    {
        $modules = $this->getModuleDirectories();
        $dependencies = [];

        foreach ($modules as $module) {
            $files = File::allFiles(app_path($module));
            $imports = [];

            foreach ($files as $file) {
                $content = $file->getContents();
                preg_match_all('/use App\\\\(\w+)\\\\/', $content, $matches);
                $imports = array_merge($imports, $matches[1]);
            }

            $externalDeps = array_unique(
                array_filter($imports, fn ($dep) => $dep !== $module)
            );

            $dependencies[$module] = $externalDeps;
        }

        foreach ($dependencies as $module => $deps) {
            $this->info("{$module} depends on: " . implode(', ', $deps));
        }

        $this->detectCircularDependencies($dependencies);
    }

    private function detectCircularDependencies(array $dependencies): void
    {
        foreach ($dependencies as $moduleA => $depsA) {
            foreach ($depsA as $moduleB) {
                if (isset($dependencies[$moduleB]) && in_array($moduleA, $dependencies[$moduleB])) {
                    $this->warn("Circular dependency: {$moduleA} <-> {$moduleB}");
                }
            }
        }
    }

    private function getModuleDirectories(): array
    {
        return collect(File::directories(app_path()))
            ->map(fn ($dir) => basename($dir))
            ->filter(fn ($dir) => !in_array($dir, ['Console', 'Exceptions', 'Http', 'Providers']))
            ->values()
            ->all();
    }
}
Enter fullscreen mode Exit fullscreen mode

Node.js Dependency Mapping

import fs from 'fs';
import path from 'path';

interface ModuleDependency {
  module: string;
  dependsOn: string[];
  files: number;
  linesOfCode: number;
}

function analyzeModuleDependencies(srcDir: string): ModuleDependency[] {
  const modules = fs.readdirSync(srcDir, { withFileTypes: true })
    .filter((d) => d.isDirectory())
    .map((d) => d.name);

  return modules.map((moduleName) => {
    const moduleDir = path.join(srcDir, moduleName);
    const files = getAllFiles(moduleDir, ['.ts', '.js']);
    const imports: string[] = [];
    let totalLines = 0;

    files.forEach((file) => {
      const content = fs.readFileSync(file, 'utf-8');
      totalLines += content.split('\n').length;

      const importRegex = /from\s+['"]\.\.\/(\w+)/g;
      let match;
      while ((match = importRegex.exec(content)) !== null) {
        if (match[1] !== moduleName) {
          imports.push(match[1]);
        }
      }
    });

    return {
      module: moduleName,
      dependsOn: [...new Set(imports)],
      files: files.length,
      linesOfCode: totalLines,
    };
  });
}

function getAllFiles(dir: string, extensions: string[]): string[] {
  const results: string[] = [];
  const entries = fs.readdirSync(dir, { withFileTypes: true });

  for (const entry of entries) {
    const fullPath = path.join(dir, entry.name);
    if (entry.isDirectory()) {
      results.push(...getAllFiles(fullPath, extensions));
    } else if (extensions.some((ext) => entry.name.endsWith(ext))) {
      results.push(fullPath);
    }
  }

  return results;
}
Enter fullscreen mode Exit fullscreen mode

Phase 2: Establish Module Boundaries

Before extracting services, enforce boundaries within the monolith. This is the most important step and the one most teams skip.

Laravel: Module Service Providers and Contracts

// app/Billing/Contracts/BillingService.php
namespace App\Billing\Contracts;

interface BillingService
{
    public function createInvoice(string $customerId, array $lineItems): Invoice;
    public function processPayment(string $invoiceId, array $paymentMethod): PaymentResult;
    public function getCustomerBalance(string $customerId): Money;
}

// app/Billing/BillingServiceProvider.php
namespace App\Billing;

use Illuminate\Support\ServiceProvider;

class BillingServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(
            Contracts\BillingService::class,
            Services\BillingServiceImpl::class
        );
    }
}

// app/Orders/Services/OrderService.php
namespace App\Orders\Services;

use App\Billing\Contracts\BillingService;

class OrderService
{
    public function __construct(private BillingService $billing) {}

    public function completeOrder(Order $order): void
    {
        $invoice = $this->billing->createInvoice(
            $order->customer_id,
            $order->lineItems->toArray()
        );

        $order->update(['invoice_id' => $invoice->id, 'status' => 'invoiced']);
    }
}
Enter fullscreen mode Exit fullscreen mode

Node.js: Module Interfaces

// src/billing/contracts.ts
export interface BillingService {
  createInvoice(customerId: string, lineItems: LineItem[]): Promise<Invoice>;
  processPayment(invoiceId: string, method: PaymentMethod): Promise<PaymentResult>;
  getCustomerBalance(customerId: string): Promise<Money>;
}

// src/billing/index.ts
export { BillingServiceImpl as BillingService } from './services/billing-service';
export type { BillingService as BillingServiceInterface } from './contracts';

// src/orders/services/order-service.ts
import type { BillingService } from '../../billing/contracts';

export class OrderService {
  constructor(private billing: BillingService) {}

  async completeOrder(order: Order): Promise<void> {
    const invoice = await this.billing.createInvoice(
      order.customerId,
      order.lineItems
    );

    await this.orderRepo.update(order.id, {
      invoiceId: invoice.id,
      status: 'invoiced',
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

The key insight: modules communicate through interfaces, not direct implementation references. When you later extract the billing module into its own service, you replace the implementation behind the interface. The calling code does not change.


Phase 3: Extract the First Service

Choose a module that is:

  • Well-bounded (few incoming dependencies)
  • Independently valuable
  • Not on the critical path (if the extraction has problems, it should not take down the whole system)

The Routing Layer

Use a proxy or feature flag to route traffic between the monolith and the new service:

Laravel: Switchable Implementation

// app/Billing/BillingServiceProvider.php
class BillingServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(Contracts\BillingService::class, function ($app) {
            $useNewService = config('features.billing_service_v2', false);

            if ($useNewService) {
                return new HttpBillingServiceClient(
                    config('services.billing.url'),
                    config('services.billing.api_key'),
                );
            }

            return new Services\BillingServiceImpl();
        });
    }
}

// The HTTP client talks to the extracted billing service
class HttpBillingServiceClient implements Contracts\BillingService
{
    public function __construct(
        private string $baseUrl,
        private string $apiKey,
    ) {}

    public function createInvoice(string $customerId, array $lineItems): Invoice
    {
        $response = Http::withToken($this->apiKey)
            ->timeout(10)
            ->retry(3, 100)
            ->post("{$this->baseUrl}/api/invoices", [
                'customer_id' => $customerId,
                'line_items' => $lineItems,
            ]);

        if ($response->failed()) {
            throw new BillingServiceException("Failed to create invoice: " . $response->body());
        }

        return Invoice::fromArray($response->json());
    }
}
Enter fullscreen mode Exit fullscreen mode

Node.js: Strategy Pattern Switch

import type { BillingService } from './contracts';
import { LocalBillingService } from './services/billing-service';
import { RemoteBillingService } from './services/remote-billing-service';

export function createBillingService(): BillingService {
  if (process.env.BILLING_SERVICE_V2 === 'true') {
    return new RemoteBillingService(
      process.env.BILLING_SERVICE_URL!,
      process.env.BILLING_SERVICE_API_KEY!
    );
  }

  return new LocalBillingService();
}

class RemoteBillingService implements BillingService {
  constructor(
    private baseUrl: string,
    private apiKey: string,
  ) {}

  async createInvoice(customerId: string, lineItems: LineItem[]): Promise<Invoice> {
    const response = await fetch(`${this.baseUrl}/api/invoices`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${this.apiKey}`,
      },
      body: JSON.stringify({ customer_id: customerId, line_items: lineItems }),
    });

    if (!response.ok) {
      throw new Error(`Billing service error: ${response.status}`);
    }

    return response.json();
  }
}
Enter fullscreen mode Exit fullscreen mode

Phase 4: Data Migration Strategy

The hardest part of service extraction is data ownership. When the billing module becomes its own service, who owns the billing tables?

Shared Database Phase

Initially, the new service reads from the same database as the monolith. This avoids data migration complexity during the initial extraction.

Data Sync Phase

Introduce event-based data synchronisation. The new service maintains its own datastore, kept in sync via events:

class BillingDataSyncListener
{
    public function handle(CustomerUpdated $event): void
    {
        Http::post(config('services.billing.url') . '/api/sync/customer', [
            'customer_id' => $event->customerId,
            'name' => $event->name,
            'email' => $event->email,
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Full Ownership Phase

The new service owns its data completely. The monolith no longer writes to billing tables. All access goes through the service API.


Phase 5: Zero-Downtime Cutover

The cutover from monolith to new service should be invisible to users:

  1. Shadow mode: Both implementations run. The new service processes requests but responses come from the monolith. Compare outputs for correctness.

  2. Canary rollout: Route 5% of traffic to the new service. Monitor error rates, latency, and correctness. Gradually increase.

  3. Full rollout: Route 100% to the new service. Keep the monolith implementation for 2 weeks as a rollback option.

  4. Cleanup: Remove the old implementation from the monolith.

class BillingServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(Contracts\BillingService::class, function () {
            $strategy = config('features.billing_migration_phase');

            return match ($strategy) {
                'monolith' => new Services\BillingServiceImpl(),
                'shadow' => new ShadowBillingService(
                    primary: new Services\BillingServiceImpl(),
                    shadow: new HttpBillingServiceClient(/*...*/),
                ),
                'canary' => new CanaryBillingService(
                    legacy: new Services\BillingServiceImpl(),
                    modern: new HttpBillingServiceClient(/*...*/),
                    percentage: (int) config('features.billing_canary_percentage', 5),
                ),
                'modern' => new HttpBillingServiceClient(/*...*/),
                default => new Services\BillingServiceImpl(),
            };
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Measuring Migration Success

Track these metrics throughout the migration:

Metric Purpose
Deployment frequency Should increase as modules become independent
Lead time for changes Should decrease as codebase complexity drops
Test suite runtime Should decrease per module
Incidents per deployment Should decrease with smaller change sets
Mean time to recovery Should decrease with independent rollback capability

Production Results from Past Migrations

Metric Before (Monolith) After (Modular)
Deployment frequency 1–2 per week 3–5 per day
Full test suite runtime 35 minutes 8 minutes (per module)
Average deploy risk score High Low (per module)
Time to onboard new developer 3 weeks 1 week
Query execution time 1.8s average 180ms average (60% reduction)
System performance Baseline 40% improvement

Key Takeaways

  1. Never rewrite. Always migrate incrementally. The strangler fig pattern lets you replace the monolith piece by piece while keeping the system running.

  2. Establish boundaries before extracting. Enforce module interfaces within the monolith first. This makes extraction a configuration change, not a rewrite.

  3. Start with a low-risk module. Pick something well-bounded and non-critical for your first extraction. Build confidence before tackling core business logic.

  4. Use feature flags for cutover. Shadow mode, canary rollouts, and instant rollback make the migration safe. Never do a big bang switchover.

  5. Solve data ownership explicitly. Shared database → event-based sync → full ownership is the safest data migration path.

  6. Measure continuously. Deployment frequency, test runtime, and incident rate tell you whether the migration is actually improving things.


Conclusion

Migrating from a monolith to a modular architecture is not a technical project — it is an engineering discipline applied over weeks and months. The strangler fig pattern makes it safe. Interface-first module design makes it clean. Feature flags make it reversible. And continuous measurement makes it accountable.

Whether you are working in Laravel or Node.js, the approach is the same: understand what you have, enforce boundaries, extract behind interfaces, migrate data carefully, and cut over gradually. The monolith did not become painful overnight, and the fix will not happen overnight either. But each step makes the system better, and the cumulative impact is transformative.


Top comments (0)