From Monolithic CLIs to Modular Plugins: Applying the Strangler Fig Pattern

Published: (December 3, 2025 at 10:41 PM EST)
4 min read
Source: Dev.to

Source: Dev.to

TL;DR – The Safe Migration Strategy

  • Extract commands to external plugins (publish as beta)
  • Add plugins as dependencies in your core (v2.x – zero breaking changes)
  • Gather feedback, iterate, stabilize
  • Remove dependencies in v3.0.0 (breaking change, but prepared)
  • Users install only what they need

Result: safe migration + independent plugin evolution


The Problem: How CLIs Become Unmaintainable

Your CLI probably started like this:

cli/
├── commands/
   ├── deploy.js
   └── status.js
├── utils/
   └── helpers.js
└── index.js

Clean. Simple. Everything in its place.

Six months later:

cli/
├── commands/      (37 files)
├── utils/         (23 files)
├── services/      (15 files)
├── integrations/  (12 files)
└── shared/        (who even knows?)

Now you’re dealing with:

  • Tight coupling – every command imports 10+ utility files
  • Shared state nightmares – global config that everything touches
  • Risky releases – one change requires testing the entire CLI
  • Slow development – adding features takes weeks of coordination
  • Breaking‑change hell – can’t evolve one part without breaking everything

Sound familiar? You’ve built a monolith.


The Backend Lesson: From Monoliths to Modularity

The Old Way (Monolithic API)

Everything in one codebase

Tightly coupled domains

Coordinated releases

High‑risk deployments

Slow feature velocity

The Modern Way (Microservices / Modular)

Thin API Gateway

Isolated domain services

Independent deployments

Versioned contracts

Fast, safe iterations

Key insight: isolate domains, establish clear boundaries, enable independent evolution. This works for CLIs too.


The Solution: Core + Plugins Architecture

The Thin Core

Your core should be boring and stable:

core/
├── auth/           // Auth logic
├── config/         // Config management
├── dispatcher/     // Command routing
├── utils/          // Cross‑cutting concerns
└── plugin-loader/  // Plugin discovery

The core provides infrastructure and gets out of the way.

The Plugins

Each plugin is a self‑contained domain:

plugins/
├── deploy-plugin/
│   ├── commands/
│   ├── tests/
│   ├── README.md
│   └── package.json   // Independent versioning!
├── logs-plugin/
└── backup-plugin/

Each plugin:

  • Owns exactly one responsibility
  • Has its own semantic version
  • Installs independently (npm i @cli/deploy-plugin)
  • Changes without affecting others
  • Tests in complete isolation

The Migration Strategy: Strangler Fig Pattern

The Strangler Fig Pattern (Martin Fowler) lets you migrate gradually without a risky big‑bang rewrite.

Step 1 – Release External Plugins in Beta

# Release your first plugin in beta
npm publish @my-cli/deploy-plugin@1.0.0-beta.1
npm publish @my-cli/logs-plugin@1.0.0-beta.1

Why beta? Users can opt‑in to test, you can iterate quickly, and breaking changes are expected.

// deploy-plugin/package.json
{
  "name": "@my-cli/deploy-plugin",
  "version": "1.0.0-beta.1",
  "main": "dist/index.js",
  "peerDependencies": {
    "@my-cli/core": "^2.0.0"
  }
}

Step 2 – Add Plugins as Dependencies in Core (Temporarily)

// core/package.json
{
  "name": "@my-cli/core",
  "version": "2.5.0",
  "dependencies": {
    "@my-cli/deploy-plugin": "^1.0.0-beta.1",
    "@my-cli/logs-plugin": "^1.0.0-beta.1"
  }
}
  • Users install core → plugins come automatically
  • No breaking changes for existing users
  • Plugins work standalone for early adopters

Step 3 – Gradually Migrate Commands

v2.5.0 (Initial Beta)

deploy → Plugin (bundled as dependency)
logs   → Legacy in core
backup → Legacy in core

v2.8.0 (Beta 2)

deploy → Plugin (bundled as dependency)
logs   → Plugin (bundled as dependency)
backup → Legacy in core

Each step ships safely, users see no difference, and plugins mature in the wild.

Step 4 – The Big Switch: Remove Dependencies (v3.0.0)

// core/package.json (v3.0.0)
{
  "name": "@my-cli/core",
  "version": "3.0.0",
  "dependencies": {
    // No more plugin dependencies!
  },
  "peerDependencies": {
    // Plugins are now optional
  }
}

Migration guide for users

# Old way (v2.x) – everything bundled
npm install -g @my-cli/core

# New way (v3.x) – install what you need
npm install -g @my-cli/core
npm install -g @my-cli/deploy-plugin   # only if you need deploy
npm install -g @my-cli/logs-plugin     # only if you need logs

Why this is a major version: breaking change – plugins are no longer auto‑installed, giving users more control, a smaller footprint, and faster installs.

Step 5 – Users Install According to Their Needs

# Minimal installation (just core utilities)
npm install -g @my-cli/core

# À la carte (only what I use)
npm install -g @my-cli/core @my-cli/deploy-plugin

# Everything (opt‑in to all plugins)
npm install -g @my-cli/all   # metapackage

Benefits

  • Smaller installations (e.g., 15 MB vs 250 MB)
  • Faster startup times
  • Independent evolution of each plugin
  • Safer, more controlled releases

By treating your CLI like a microservice ecosystem and applying the Strangler Fig Pattern, you can transition from a monolithic codebase to a modular, plugin‑driven architecture with minimal risk and maximum flexibility.

Back to Blog

Related posts

Read more »