DEV Community

Cover image for From Monolithic CLIs to Modular Plugins: Applying the Strangler Fig Pattern
Aman Kumar
Aman Kumar

Posted on

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

How the same patterns that saved backend APIs can transform your CLI architecture


πŸ’‘ TL;DR - The Safe Migration Strategy:

  1. Extract commands to external plugins (publish as beta)
  2. Add plugins as dependencies in your core (v2.x - zero breaking changes)
  3. Gather feedback, iterate, stabilize
  4. Remove dependencies in v3.0.0 (breaking change, but prepared)
  5. 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
Enter fullscreen mode Exit fullscreen mode

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?)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The Modern Way (Microservices/Modular)

Thin API Gateway
↓
Isolated domain services
↓
Independent deployments
↓
Versioned contracts
↓
Fast, safe iterations
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

v2.8.0 (Beta 2)

 deploy  β†’ Plugin (bundled as dependency)
 logs    β†’ Plugin (bundled as dependency)
 backup  β†’ Legacy in core
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

After:

One breaking change β†’ That plugin bumps to v3.0.0
                    β†’ Everything else stays put
Enter fullscreen mode Exit fullscreen mode

Users Install Only What They Need

Before:

npm install -g my-cli
# Downloads: 250MB (includes 30 commands you never use)
Enter fullscreen mode Exit fullscreen mode

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!)
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode
npm install -g @my-cli/all  # One command, everything installed
Enter fullscreen mode Exit fullscreen mode

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/shared package
  • 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:

  1. Build the thin core first
  2. Make your first command a plugin (proves the pattern)
  3. Release everything as stable v1.0.0 together
  4. Every new feature becomes a new plugin
  5. 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:

  1. Extract to external plugins (beta)
  2. Bundle them temporarily (v2.x - safe transition)
  3. Remove dependencies (v3.0.0 - breaking change, but prepared)
  4. 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)