How the same patterns that saved backend APIs can transform your CLI architecture
π‘ 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
Remember when your CLI was just a little tool with 3 commands? Yeah, me too.
Then product asked for "just one more feature"... and another... and another. Now you're maintaining a 50-command behemoth where changing one line breaks three unrelated commands. Welcome to the CLI monolith trap.
Here's the thing: CLIs go through the exact same evolution as backend APIs. And guess what? The same architectural patterns that rescued backend teams work beautifully for CLIs too.
In this post, I'll show you:
- Why CLI monoliths form (spoiler: it's inevitable)
- How plugin architectures mirror microservices
- How to use the Strangler Fig Pattern for safe migration
- Real benefits you'll see immediately
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?)
And 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 painfully familiar? You've built a monolith.
π‘ The Backend Lesson: From Monoliths to Modularity
The backend world already solved this problem. Let me show you the evolution:
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
The key insight?
Isolate domains. Establish clear boundaries. Enable independent evolution.
Here's the beautiful part: This works for CLIs too!
ποΈ The Solution: Core + Plugins Architecture
Think of it like this:
- Core = API Gateway (stable, lightweight, routing layer)
- Plugins = Microservices (isolated domains with independent lifecycles)
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
That's it. 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
"Cool story," you're thinking, "but I have a 50k-line CLI. I'm not rewriting everything."
Good news: You don't have to!
Enter the Strangler Fig Pattern (from Martin Fowler). It lets you migrate gradually without a risky big-bang rewrite.
How It Works
The pattern is named after strangler fig trees that grow around other trees, eventually replacing them. Same concept for code.
Here's how to apply it to your CLI:
Step 1: Release External Plugins in Beta
Start by extracting functionality into external plugins and release them 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
- Breaking changes are expected
- Gives you real-world feedback
// 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)
During migration, include plugins as dependencies in your core package:
// core/package.json
{
"name": "@my-cli/core",
"version": "2.5.0",
"dependencies": {
// Legacy plugins bundled during migration
"@my-cli/deploy-plugin": "^1.0.0-beta.1",
"@my-cli/logs-plugin": "^1.0.0-beta.1"
}
}
What happens:
- Users install core β plugins come along automatically
- No breaking changes for existing users
- Plugins work standalone for early adopters
- You validate the plugin architecture safely
Step 3: Gradually Migrate Commands
Track your progress as you extract:
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 to production safely
- Users see no difference
- Plugins mature in the wild
- You gather feedback
Step 4: The Big Switch - Remove Dependencies (v3.0.0)
Once all plugins are stable, release a breaking change that removes plugin dependencies:
// 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 no longer auto-installed
- Users must explicitly install plugins they use
- But: more control, smaller footprint, faster installs
Step 5: Users Install According to Their Needs
Now users have full control:
# 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 (15MB vs 250MB)
- Faster startup (fewer plugins to load)
- Better security (only install trusted plugins)
- Independent updates (update deploy plugin without touching core)
The Result: Independent Plugin Releases
Once your plugins are stable and external, this is where the magic happens:
The Old Way (v2.x - bundled):
# Breaking change in deploy command
# Forces major version bump for ENTIRE CLI
npm publish @my-cli/core@3.0.0
# Now everyone needs to:
# - Update the entire CLI
# - Regression test everything
# - Coordinate with all teams
The New Way (v3.x - modular):
# Breaking change in deploy plugin only
npm publish @my-cli/deploy-plugin@3.0.0
# Everything else?
# - Core stays at v3.x
# - Logs plugin stays at v1.5.3
# - Backup plugin stays at v2.1.0
# - Zero cross-testing needed
This is massive for teams with multiple stakeholders!
Even better: Users who don't use deploy aren't affected at all!
End State: Minimal Core, Maximum Flexibility
After migration, your core becomes tiny and stable:
// Final core: ~500 lines total
core/
βββ auth.ts // 150 lines
βββ config.ts // 120 lines
βββ dispatcher.ts // 100 lines
βββ loader.ts // 80 lines
βββ utils.ts // 50 lines
Everything else lives in plugins:
plugins/
βββ @my-cli/deploy-plugin (v2.0.0)
βββ @my-cli/logs-plugin (v1.5.3)
βββ @my-cli/backup-plugin (v3.1.0)
βββ @my-cli/migrate-plugin (v1.0.0-beta.2)
βββ @my-cli/docs-plugin (v2.2.1)
The core is now:
- Easy to maintain (rarely changes)
- Easy to test (minimal surface area)
- Easy to understand (just infrastructure)
- Boring (in the best way!)
The Benefits: Why This Matters
Faster Release Velocity
Before:
"We need to schedule a CLI release. When can everyone test their parts? How about 2 weeks from now?"
After:
"Deploy plugin v2.4.0 is live. Changelog in Slack. Carry on!"
Isolated Breaking Changes
Before:
One breaking change β Entire CLI bumps to v3.0.0
After:
One breaking change β That plugin bumps to v3.0.0
β Everything else stays put
Users Install Only What They Need
Before:
npm install -g my-cli
# Downloads: 250MB (includes 30 commands you never use)
After:
npm install -g @my-cli/core # 12MB
npm install -g @my-cli/deploy-plugin # 8MB
npm install -g @my-cli/logs-plugin # 5MB
# Downloads: 25MB (only what you need!)
Faster installs. Smaller footprint. Happier users.
π€ "Won't this break my existing users?"
Not with the beta approach! That's the beauty of it:
- v2.x: Plugins bundled as dependencies β Zero breaking changes
- v3.x: Plugins separate β Breaking change, but well-communicated
- Users get months to prepare during the beta phase
- Migration guide makes the transition smooth
"Why not just make v3.0 immediately?"
Risk management!
By bundling plugins as dependencies in v2.x first:
- You prove the plugin architecture works
- Users can test beta plugins early
- You gather real-world feedback
- No one's workflow breaks during migration
- The v3.0 switch is just removing deps (lower risk)
"What about users who want everything?"
Create a metapackage:
// @my-cli/all/package.json
{
"name": "@my-cli/all",
"version": "3.0.0",
"dependencies": {
"@my-cli/core": "^3.0.0",
"@my-cli/deploy-plugin": "^2.0.0",
"@my-cli/logs-plugin": "^1.5.3",
"@my-cli/backup-plugin": "^3.1.0"
}
}
npm install -g @my-cli/all # One command, everything installed
Best of both worlds!
"How do I handle shared code between plugins?"
Rule of thumb:
- Used by one plugin β Keep it in the plugin
- Used by two plugins β Maybe extract to
@my-cli/sharedpackage - Used by three+ plugins β Belongs in core utilities
- Used by external plugins β Definitely in core (stable API)
"What if I'm building a new CLI from scratch?"
Even better! Start with the plugin architecture from day one:
- Build the thin core first
- Make your first command a plugin (proves the pattern)
- Release everything as stable v1.0.0 together
- Every new feature becomes a new plugin
- Future you will be so grateful
But: Still consider releasing as a monolith initially (v1.x), then split in v2.x once you understand usage patterns.
What Changed?
Benefits you get immediately:
- Clear interface - Plugin contract vs monolith spaghetti
- Injected dependencies - No more deep imports
- Self-contained logic - Plugin owns its domain
- Trivial testing - Mock the context, done
- Independent releases - Deploy updates without touching core
- Smaller installs - 25MB vs 250MB
Wrapping Up
CLIs are going through the same architectural evolution that backend systems experienced years ago:
| From | To |
|---|---|
| Monolithic | Modular |
| Tightly Coupled | Plugin-Based |
| Coordinated Releases | Independent Lifecycles |
| Risky Changes | Isolated Changes |
| Slow Development | Fast Iterations |
The Strangler Fig Pattern with Beta Releases gives you a safe, incremental path to get there without a risky rewrite.
The secret sauce:
- Extract to external plugins (beta)
- Bundle them temporarily (v2.x - safe transition)
- Remove dependencies (v3.0.0 - breaking change, but prepared)
- Independent evolution forever
By keeping your core thin and moving features into independent plugins, you create a CLI that is:
- Easier to maintain - Smaller, focused codebases
- Safer to extend - Changes are isolated
- Faster to iterate - No coordination needed
- More stable for users - Breaking changes are scoped
- Future-proof - New plugins without touching core
- Lighter weight - Install only what you need
If you're building or maintaining a CLI, modularity isn't optional anymore - it's your architectural advantage.
Start today: Extract one command to a beta plugin. See how it feels. You'll never look back.
Have you hit the CLI monolith wall? What's your biggest pain point with CLI architecture?
Thinking about plugins? What's holding you back?
Top comments (0)