From Monolithic CLIs to Modular Plugins: Applying the Strangler Fig Pattern
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.